Compare commits
2
Commits
master
..
e70b671062
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e70b671062 | ||
|
|
3646825485 |
@@ -7,14 +7,14 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-and-push-image:
|
||||
name: 'Build & Push Image'
|
||||
env:
|
||||
REGISTRY_USERNAME: nicholas
|
||||
IMAGE_REGISTRY: git.uuard.com
|
||||
IMAGE_NAME: nicholas/web-log
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
runs-on: shared-ci
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- name: ⚙️ Setup Hugo
|
||||
uses: peaceiris/actions-hugo@v3
|
||||
with:
|
||||
hugo-version: '0.164.0'
|
||||
hugo-version: '0.148.1'
|
||||
extended: true
|
||||
|
||||
- name: 🏗️ Build Hugo site
|
||||
@@ -43,54 +43,7 @@ jobs:
|
||||
|
||||
- name: 🚀 Push Docker image
|
||||
run: docker push $IMAGE_REGISTRY/$IMAGE_NAME:$IMAGE_TAG
|
||||
|
||||
|
||||
- name: 🧹 Prune images
|
||||
if: always()
|
||||
run: docker image prune -f
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: web-log-deploy
|
||||
env:
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install kubectl
|
||||
env:
|
||||
KUBECTL_VERSION: v1.36.2
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSLO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
|
||||
curl -fsSLO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256"
|
||||
printf '%s %s\n' "$(cat kubectl.sha256)" kubectl | sha256sum -c
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
install -m 0755 kubectl "$HOME/.local/bin/kubectl"
|
||||
printf '%s\n' "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Configure Kubernetes access
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.kube
|
||||
printf '%s' "$KUBE_CONFIG" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
kubectl auth can-i update deployments --namespace nicholas
|
||||
|
||||
- name: Deploy web log and files service
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sed -E -i "s#newTag: [0-9a-f]+#newTag: ${IMAGE_TAG}#" k8s/kustomization.yaml
|
||||
grep -q "newTag: ${IMAGE_TAG}$" k8s/kustomization.yaml
|
||||
kubectl apply --kustomize k8s
|
||||
if ! kubectl rollout status deployment/web-log \
|
||||
--namespace nicholas --timeout=180s; then
|
||||
kubectl rollout undo deployment/web-log --namespace nicholas
|
||||
kubectl rollout status deployment/web-log \
|
||||
--namespace nicholas --timeout=180s
|
||||
exit 1
|
||||
fi
|
||||
kubectl rollout status deployment/web-log-files \
|
||||
--namespace nicholas --timeout=180s
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
.gallery-new {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.gallery-new figure {
|
||||
aspect-ratio: 1 / 1;
|
||||
background: #f2f2f2;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gallery-new .lightgallery {
|
||||
aspect-ratio: 1 / 1;
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gallery-new .lightgallery img {
|
||||
display: block;
|
||||
height: 100% !important;
|
||||
max-width: none;
|
||||
object-fit: cover !important;
|
||||
transition: filter 160ms ease, transform 160ms ease;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.gallery-new .lightgallery:focus-visible img,
|
||||
.gallery-new .lightgallery:hover img {
|
||||
filter: brightness(0.9);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.gallery-new figcaption {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["vehicle"]
|
||||
tags = ["maintenance", "oil-change", "engine-coolant"]
|
||||
date = 2024-04-27T05:30:14Z
|
||||
description = "Changing the engine oil and filter at home while monitoring a persistent coolant leak and learning to work safely beneath the car."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "2024-04-maintenance"
|
||||
title = "🚗 Car Maintenance - Oil, Filter, Engine Coolant"
|
||||
@@ -102,4 +101,4 @@ I bought a pair of car ramps and wheel chocks. The reviews are great, no one see
|
||||
src="oil-change-old-oil.jpg"
|
||||
caption="Spent oil" >}}
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["christmas", "appetizers", "pretzels"]
|
||||
date = 2024-12-20T10:30:00Z
|
||||
description = "Making travel-friendly Christmas Eve appetizers, including plain and cinnamon-sugar pretzel nuggets with beer cheese."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "2024-christmas-eve-appetizer-party"
|
||||
title = "🎄2024 Christmas Eve Party Appetizers"
|
||||
@@ -55,4 +54,4 @@ I read their site for one minute and already I see what they are trying to do an
|
||||
|
||||
## Ideas for next time
|
||||
- Quiche - Caramelized Onion, Bacon, Cheese
|
||||
- Christmas Focaccia
|
||||
- Christmas Focaccia
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["plants"]
|
||||
tags = ["gardening", "raised-bed", "herbs"]
|
||||
date = 2024-04-23T13:53:30Z
|
||||
description = "Building and planting a raised-bed vegetable garden alongside a collection of herbs for the 2024 growing season."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "2024-garden"
|
||||
title = "🥕 2024 Garden"
|
||||
@@ -31,8 +30,8 @@ Each time I visited to help water and prune, I tried to remember to photograph t
|
||||
|
||||
## Photos
|
||||
### Vegetable Garden
|
||||
{{< gallery name="vegetable-garden-gallery" >}}
|
||||
{{< gallerynew name="vegetable-garden-gallery" >}}
|
||||
|
||||
|
||||
## Herb Garden
|
||||
{{< gallery name="herb-garden-gallery" >}}
|
||||
{{< gallerynew name="herb-garden-gallery" >}}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["vehicle"]
|
||||
tags = ["maintenance", "oil-change", "engine-coolant"]
|
||||
date = 2025-03-01T08:36:00-06:00
|
||||
description = "Repeating a home oil change and checking the vehicle's ongoing coolant problem."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "2025-03-car-maintenance"
|
||||
title = "🚗 Oil Change & Engine Coolant"
|
||||
@@ -19,4 +18,4 @@ I did not do as well as I did the first time. I poured in too much oil, an amoun
|
||||
alt="Car engine oil dipstick indicator shows oil level is within acceptable levels"
|
||||
caption="Perfect dipstick" >}}
|
||||
|
||||
It was much easier this time, as can be expected. Done.
|
||||
It was much easier this time, as can be expected. Done.
|
||||
@@ -1,35 +0,0 @@
|
||||
+++
|
||||
categories = ["build","plants"]
|
||||
tags = ["plants"]
|
||||
date = 2025-08-08T08:00:00-05:00
|
||||
description = "Upsizing a houseplant cart with wire shelving, longer shop lights, and cleaner power and lighting arrangements."
|
||||
draft = false
|
||||
slug = "2025-08-08-plant-cart-upgrade"
|
||||
title = "🛒 Plant Cart Upgrade"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I need to upsize the plant cart. The current cart is undersized so I will replace it with a very large wire shelving unit.
|
||||
|
||||
## Overview
|
||||
I use cable ties to affix the shop lights to the underside of the wire shelves. There are now three light units:
|
||||
- 4 ft shop light (2)
|
||||
- 2 ft shop light
|
||||
|
||||
The 2 ft shop light was meant for the old smaller plant cart. New lights are 4 ft long, matching the length of the shelving unit.The lights are powered by a power strip with a 6 ft cord. It is attached to the side of the wire shelf with yet more cable ties. I also removed the overhead EMT conduit-mounted spotlights since they have been replaced by a shop light. It is a cleaner look.
|
||||
|
||||
|
||||
## Images
|
||||
|
||||
{{< image src="images/members-mark-6-tier-wire-shelving.png" >}}
|
||||
|
||||
|
||||
{{< image src="images/braun-10000-lumen-LED-shop-light.png" >}}
|
||||
|
||||
|
||||
{{< image src="images/shop-light-mounted.JPG" >}}
|
||||
|
||||
|
||||
{{< image src="images/plant-cart.JPG" >}}
|
||||
|
||||
✅ Done.
|
||||
@@ -1,37 +0,0 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["pizza", "bread", "meal-prep"]
|
||||
date = 2025-12-07T09:00:00Z
|
||||
description = "Batch par-baking cold-fermented pizza crusts for convenient future meals while putting winter oven heat to use."
|
||||
draft = false
|
||||
slug = "2025-12-07-mod-pizza-crust-par-bake"
|
||||
title = "🫓 Par-baking Pizza Crusts"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
It is winter time. We live on the third and top floor of an apartment complex, top of the thermal strata. My downstairs neighbors' gas heaters, 250 W of plant lights, a server rack of computer equipment (which I have made some effort to make energy efficient for summertime), and various other, now 100% efficient heat-making household electrical appliances are all that are needed to heat our apartment, even at sustained temperatures as low as ~20°F.
|
||||
|
||||
This is the perfect time to batch-par-bake a few pizza crusts. No waste heat!
|
||||
|
||||
We made the dough on Saturday night, cold-fermented it in the fridge overnight in little pizza dough proofing pans. A half size sheet pan would not fit in the refrigerator. I moved them to a parchment-lined half size sheet pan to come up to room temperature the following day.
|
||||
|
||||
|
||||
## Process
|
||||
The process is:
|
||||
- mix dough
|
||||
- divide
|
||||
- shape into balls
|
||||
- cold ferment 24-72 hr
|
||||
- allow to warm to room temperature
|
||||
- preheat baking steel 45 min @ 500° F
|
||||
- roll out to size (in this case, 14″)
|
||||
- load onto parchment paper
|
||||
- dock dough, thoroughly
|
||||
- launch onto baking steel
|
||||
- bake ≈ 1 min
|
||||
- invert, cool on rack ≈ 10 min
|
||||
- freeze immediately
|
||||
|
||||
## Images
|
||||
|
||||
{{< gallery name="par-bake-pizza-dough-gallery" >}}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
images:
|
||||
- src: "images/dough-in-proofing-pan.JPG"
|
||||
alt: "Dough in proofing pan"
|
||||
caption: "Dough in proofing pan"
|
||||
- src: "images/dough-in-proofing-box.JPG"
|
||||
alt: "Dough in proofing box"
|
||||
caption: "Dough in proofing box"
|
||||
- src: "images/dough-rolled-circle.JPG"
|
||||
alt: "Dough rolled flat into a circle"
|
||||
caption: "Dough rolled flat into a circle"
|
||||
- src: "images/doughs-rolled-circle.JPG"
|
||||
alt: "Many dough rolled into circle"
|
||||
caption: "Many dough rolled into circle"
|
||||
- src: "images/par-baked-crusts.JPG"
|
||||
alt: "Par-baked pizza crusts"
|
||||
caption: "Par-baked pizza crusts"
|
||||
@@ -1,33 +0,0 @@
|
||||
+++
|
||||
categories = ["build"]
|
||||
tags = ["server-rack", "cable-management"]
|
||||
date = 2025-12-10T20:03:00-06:00
|
||||
description = "Extending a server rack, restoring its wheels, and adding tie bars and hook-and-loop straps for cleaner cable routing."
|
||||
draft = false
|
||||
slug = "2025-12-10-cable-management"
|
||||
title = "🔌 Cable Managing"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
My computer rack would benefit from some better cable management. To facilitate this I need to make some modifications to the rack:
|
||||
|
||||
- Extend the rack to ≈30 in, up from ≈24 in. This wil give me more space at the rear to route cables.
|
||||
- Move server chassis down for 0U gap. This will give me more space at the top.
|
||||
- Put wheels back on the rack. This will allow me to roll out the entire rack from the wall for better access.
|
||||
|
||||
I purchased these server rack tie bars to give me something to fasten the cables to. I use hook-and-loop straps for this.
|
||||
|
||||
{{< image
|
||||
src="images/server-rack-tie-bar-amazon.png"
|
||||
alt="Screenshot of Amazon product Server rack tie bar"
|
||||
caption="Tie bar" >}}
|
||||
|
||||
|
||||
Computer desk needs tidying cables too. I attached a spare wire shelf to the underside of the desk using magnetic hooks. The hook base attaches to the desk's metal support rails.
|
||||
|
||||
{{< image
|
||||
src="images/under-desk-cable-management.JPG"
|
||||
alt="View under desk" >}}
|
||||
|
||||
## Images
|
||||
{{< gallery name="server-rack-gallery" >}}
|
||||
@@ -1,28 +0,0 @@
|
||||
images:
|
||||
- src: "images/server-rack-attach-wheels-half-1.JPG"
|
||||
alt: ""
|
||||
caption: ""
|
||||
- src: "images/server-rack-attach-wheels-half-2.JPG"
|
||||
alt: ""
|
||||
caption: ""
|
||||
- src: "images/server-rack-assembled-hardware.JPG"
|
||||
alt: ""
|
||||
caption: ""
|
||||
- src: "images/server-rack-rear-high.JPG"
|
||||
alt: ""
|
||||
caption: ""
|
||||
- src: "images/server-rack-rear-eye-level.JPG"
|
||||
alt: ""
|
||||
caption: ""
|
||||
- src: "images/server-rack-roll-out-landscape.JPG"
|
||||
alt: ""
|
||||
caption: ""
|
||||
- src: "images/server-rack-roll-out-portrait.JPG"
|
||||
alt: ""
|
||||
caption: ""
|
||||
- src: "images/under-desk-cable-management.JPG"
|
||||
alt: ""
|
||||
caption: ""
|
||||
- src: "images/cable-management-result.JPG"
|
||||
alt: ""
|
||||
caption: ""
|
||||
@@ -1,64 +0,0 @@
|
||||
+++
|
||||
categories = ["christmas"]
|
||||
tags = ["christmas", "decorations", "lighting"]
|
||||
date = 2025-10-28T16:00:00Z
|
||||
description = "Decorating for Christmas 2025 with lights, a tree, a wreath, and an exchange of gifts."
|
||||
draft = false
|
||||
slug = "2025-christmas"
|
||||
title = "🎄 Christmas 2025"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
We need Christmas decorations. The most important holiday. Late October is not too soon. How many Christmases do we get in a life? I do not want to think of it.
|
||||
|
||||
|
||||
## ✨ Lights
|
||||
I attached standard Christmas string lights to the plant cart, a little haphazardly. It will do. I am not sure I will ever take these down. I used maybe 50 cable ties and it will a little tedious to remove them all. Also, it looks not explicitly Christmasy in the mean time, and the room could use more light, although they seem to exhibit a kind of flickering which I read is due to cheap half-wave rectified LED drivers. 60 Hz flickering.
|
||||
|
||||
{{< image
|
||||
src="images/plant-cart-christmas-lights.JPG"
|
||||
caption="Plant cart Christmas lights" >}}
|
||||
|
||||
## 🌲 Tree
|
||||
The tree is 3 ft tall unlit tree, ($15 at Hobby Lobby). I cannot trust that built-in LEDs on such disposable trees will survive long, so I outfit the tree with removable fairy lights.
|
||||
|
||||
A tree skirt covers the private parts of the tree. Also, battery-powered mini Christmas lights.
|
||||
|
||||
{{< image
|
||||
src="images/christmas-tree-night.JPG"
|
||||
caption="Christmas tree glowing at night" >}}
|
||||
|
||||
## 🍃 Wreath
|
||||
Bailey made a wreath a very long time ago and it continues to decay each year. It will be a shame when it is no longer with us because it is beautiful. We hang this on the front door.
|
||||
|
||||
{{< image
|
||||
src="images/wreath-day.JPG"
|
||||
caption="Wreath" >}}
|
||||
|
||||
{{< image
|
||||
src="images/wreath-night.JPG"
|
||||
caption="Wreath glowing at night" >}}
|
||||
|
||||
## 🎁 Gifts for Me
|
||||
Increasing dietary fiber could be a good thing for us, but the process of preparing digestible beans is annoying so I bought us a pressure cooker (**Instant Pot® Pro™ 6QT**). I made rice and it took 5 minutes. Incredible. Apparently legumes can be finished in something like 30 minutes. Wow.
|
||||
|
||||
I can play audio through the monitor, but of course they remind me of being on public transportation. Even these very cheap, small bookshelf speakers (**Micca MB42X G2**) are way overpowered for PC speakers, but I had a 50 W stereo amplifier (**SMSL-SA50**) from a very long time ago which can drive them, so I put it to good use.
|
||||
|
||||
{{< image
|
||||
src="images/amazon-orders.png"
|
||||
caption="Christmas gifts on the way" >}}
|
||||
|
||||
## 🎁 Gifts for Thee
|
||||
Christmas ended up getting CANCELED. Too many illnesses. Now there is a pile of largely empty gift-wrapped boxes arranged merrily in the spare storage bedroom.
|
||||
|
||||
{{< image
|
||||
src="images/gift-stack.JPG"
|
||||
caption="Gifts" >}}
|
||||
|
||||
{{< image
|
||||
src="images/baileys-sourdough.JPG"
|
||||
caption="mini sourdough loaves" >}}
|
||||
|
||||
{{< image
|
||||
src="images/baileys-cookies.JPG"
|
||||
caption="pistachio shortbread cookies, ginger molasses cookies, earl grey macarons" >}}
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["plants"]
|
||||
tags = ["gardening", "vegetables", "herbs"]
|
||||
date = 2025-02-20T10:30:00-06:00
|
||||
description = "Following the 2025 garden from seeds and indoor seedlings through the herb and vegetable beds."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "2025-garden"
|
||||
title = "🍃 2025 Garden"
|
||||
@@ -14,18 +13,18 @@ It is nearly springtime as I write the first lines of this post, so it is time t
|
||||
- [Vegetable](#-vegetable-garden)
|
||||
|
||||
## 🌰 Seeds
|
||||
{{< gallery name="seed-gallery" >}}
|
||||
{{< gallerynew name="seed-gallery" >}}
|
||||
|
||||
## 🌱 Seedlings
|
||||
The seeds were started in little clumps of coco coir pellets and kept beneath a LED grow light.
|
||||
|
||||
{{< gallery name="seedling-gallery" >}}
|
||||
{{< gallerynew name="seedling-gallery" >}}
|
||||
|
||||
## 🌿 Herb Garden
|
||||
The herb garden lives outside on the deck. It will contain garlic chives and basil. They make great pizza toppings and garnishes.
|
||||
|
||||
{{< gallery name="herb-garden-gallery" >}}
|
||||
{{< gallerynew name="herb-garden-gallery" >}}
|
||||
|
||||
## 🍅 Vegetable Garden
|
||||
|
||||
{{< gallery name="vegetable-garden-gallery" >}}
|
||||
{{< gallerynew name="vegetable-garden-gallery" >}}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
+++
|
||||
categories = ["vehicle"]
|
||||
tags = ["maintenance", "repair", "engine-coolant"]
|
||||
date = 2026-01-06T16:00:00Z
|
||||
description = "Diagnosing a stuck-open engine thermostat and replacing the thermostat housing assembly to restore proper operating temperature."
|
||||
draft = false
|
||||
slug = "2026-01-06-replace-thermostat"
|
||||
title = "🌡️ Replacing Thermostat Housing Assembly"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
The engine coolant temperature gauge reports temperatures that are too low. The engine never reaches the proper operating temperature. The cabin air heating system produces only slightly hotter-than-ambient air. The upper radiator hose begins to warm immediately on starting the car. These are all symptoms of a thermostat that is stuck in the open position. I bought a new one from automotive store.
|
||||
|
||||
|
||||
## 🛠️ Parts and equipment
|
||||
I need a few tools and the replacement part:
|
||||
|
||||
- Murray Plus 221 Degree Thermostat Housing - 73021
|
||||
- car ramp (2)
|
||||
- wheel chocks (2)
|
||||
- torx E10 socket
|
||||
- torx T15 drive bit
|
||||
- hex 10mm socket
|
||||
- flat head screwdriver
|
||||
- phillips head screwdriver
|
||||
- ratchet
|
||||
- clamp pliers
|
||||
- hook pick
|
||||
- drain pan
|
||||
- DEX-COOL engine coolant
|
||||
- automotive trim removal tool
|
||||
- silicone tube (3/8 in ID)
|
||||
|
||||
{{< image
|
||||
src="images/oreilly-thermostat-part.png"
|
||||
caption="Thermostat Housing Assembly" >}}
|
||||
|
||||
|
||||
## ⚙️ Process
|
||||
I need to drain the coolant system first, then I can replace the thermostat, and finally refill the coolant.
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/coolant-system-diagram-original.png"
|
||||
caption="Coolant System Diagram" >}}
|
||||
|
||||
### drain coolant system
|
||||
- place car onto ramp, place wheel chocks under rear wheels
|
||||
- remove negative battery terminal clamp, disconnect
|
||||
- remove engine splash shield
|
||||
- place silicone drain hose over petcock nipple, place catch pan under hose
|
||||
- unscrew coolant reservoir cap
|
||||
- open petcock valve, drain coolant
|
||||
- close petcock valve, remove tube, store used coolant
|
||||
- replace splash shield
|
||||
|
||||
### replace thermostat housing assembly
|
||||
- remove air filter hose
|
||||
- remove positive crank ventilation hose
|
||||
- remove sensor connector
|
||||
- remove upper radiator hose
|
||||
- remove thermostat housing assembly
|
||||
- install replacement thermostat housing assembly
|
||||
- replace all removed components
|
||||
|
||||
I refill with new coolant and purge the coolant system of air.
|
||||
|
||||
✅ Done.
|
||||
@@ -1,34 +0,0 @@
|
||||
+++
|
||||
categories = ["build"]
|
||||
tags = ["server-rack", "woodworking"]
|
||||
date = 2026-01-22T20:00:00Z
|
||||
description = "Cutting, staining, and finishing a plywood top to turn the open space above a server rack into a usable surface."
|
||||
draft = false
|
||||
slug = "2026-01-22-server-top"
|
||||
title = "🔝 Server Top"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I used a piece of plywood to cover the void of the server rack. Its purpose is to enable me to place things atop it. I must remind myself of this singular purpose to cope with its ugliness.
|
||||
|
||||
The stain looked great, then I completely ruined the look by encasing it in a thick layer of polyurethane plastic. Now it has a wet-looking orange glow to it, and probably sheds a trillion microplastic particles into the air each day. I am a little ashamed of the decision, but life goes on and the finish is totally inconsequential to the tabletop's stated purpose.
|
||||
|
||||
I picked out a nice-looking piece at Home Depot, and had someone cut it to size for me. Then I sanded the edges to give them each a small radius, sanded the surface, vacuumed and wiped it totally clean for staining. But then I found some leftover PU and some painting pyramids and thought "why not"? I placed the plywood on top of the pyramids and slapped on several layers of way-too-thick oil-based PU with a PU foam brush. It was old and partially cured PU, impossible to apply in thin coats and hard to handle, but it did not stop me and probably should have.
|
||||
|
||||
---
|
||||
|
||||
{{< image
|
||||
src="images/server-top-raw.jpg"
|
||||
caption="Plywood cut to size" >}}
|
||||
|
||||
{{< image
|
||||
src="images/server-top-stained.jpg"
|
||||
caption="Plywood stained" >}}
|
||||
|
||||
{{< image
|
||||
src="images/server-top-complete.JPG"
|
||||
caption="Finished top" >}}
|
||||
|
||||
I am not totally unhappy with result but I could have done better. The look is competitive with the default raw, dull yellow surface finish at least. 👍
|
||||
|
||||
✅ Done.
|
||||
@@ -1,167 +0,0 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["freepbx","vm","bulkvs","sip","voip"]
|
||||
date = 2026-03-01T08:00:00Z
|
||||
description = "Building a low-cost personal VoIP phone system with FreePBX, a virtual PBX host, BulkVS, SIP, and softphone extensions."
|
||||
draft = false
|
||||
slug = "2026-03-01-voip"
|
||||
title = "☎️ VoIP"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I pay my share of the family phone plan ($15/mo), and in return I get unlimited texts, 1000 minutes of calls, and 1 GB of data. This is already extraordinarily cheap, but for how little I use the service I think I can do better, at least in terms of raw cost of owning a phone number on which I can make and receive voice calls. I can get the cost down to pennies a month, depending on how much time I spend in a phone call.
|
||||
|
||||
I do not actually intend to use this as my primary phone number, **this is just for fun**.
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/bulkvs-rates.png"
|
||||
caption="BulkVS Rates" >}}
|
||||
|
||||
|
||||
As usual, my thriftiness comes at high cost:
|
||||
- I must have an internet connection to make and receive calls.
|
||||
- Poor audio quality: I will be limited to narrowband audio, since my SIP trunk/carrier (BulkVS) does not support wideband audio.
|
||||
|
||||
## ℹ️ Overview
|
||||
I need a few pieces to make this work: a PBX (FreePBX on Debian), a SIP trunk/carrier (BulkVS), and a VoIP phone or softphone (MicroSIP, Linphone).
|
||||
|
||||
**PBX**
|
||||
|
||||
The PBX is the phone system. I run it on a small VM with FreePBX installed. It answers incoming calls, sends calls to extensions, applies inbound and outbound routes, manages voicemail, and gives SIP phones or softphones a place to register. In my setup, the PBX is the part I control.
|
||||
|
||||
**SIP Trunk**
|
||||
|
||||
The SIP trunk/carrier is the bridge to the public phone network. I use BulkVS for this. BulkVS gives me a DID, which is the phone number people call, and a SIP trunk, which is the connection FreePBX uses to send and receive calls. In this setup, BulkVS is the part that connects my FreePBX server to normal phone numbers.
|
||||
|
||||
**Softphone**
|
||||
|
||||
The VoIP phone or softphone is the device I actually talk through. A hardware VoIP phone would sit on my desk like a normal phone. A softphone is an app, such as MicroSIP on Windows or Linphone on Android. Either way, the phone registers to FreePBX as an extension.
|
||||
|
||||
It needs:
|
||||
- FreePBX server address
|
||||
- extension number
|
||||
- extension's SIP password
|
||||
|
||||
The simple version is: callers reach the BulkVS DID, BulkVS sends the call to FreePBX, and FreePBX rings my VoIP phone or softphone. For outbound calls, my phone sends the call to FreePBX, FreePBX sends it to BulkVS, and BulkVS carries it to the public phone network.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### PBX host
|
||||
|
||||
The PBX runs as a small Debian 12 VM with FreePBX installed.
|
||||
|
||||
- RAM: 2 GiB
|
||||
- Disk: 20 GiB
|
||||
- Network: static internal IP address
|
||||
- Admin: FreePBX web UI initialized
|
||||
|
||||
{{< image
|
||||
src="images/proxmox-phonepooter.png"
|
||||
caption="Proxmox VM - Debian + FreePBX" >}}
|
||||
|
||||
### Phone extension
|
||||
|
||||
I configure FreePBX with one extension for each softphone, e.g.:
|
||||
|
||||
- Type: PJSIP extension
|
||||
- Extension: `101`
|
||||
- Display name: `nicholas-mobile`
|
||||
- Secret: strong SIP password, auto-generated in FreePBX
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/freepbx-extensions.png"
|
||||
caption="FreePBX SIP extensions" >}}
|
||||
|
||||
### Softphone
|
||||
|
||||
The softphone registers to FreePBX as the extension.
|
||||
|
||||
- App: MicroSIP on Windows or Linphone on Android
|
||||
- SIP server or domain: FreePBX server address, e.g. `sip.uuard.com`
|
||||
- Username: FreePBX extension number, e.g. `104`
|
||||
- Password: extension SIP secret
|
||||
|
||||
Once the account is configured, I expect it to be 'registered'
|
||||
|
||||
{{< image
|
||||
src="images/microsip-call.png"
|
||||
caption="MicroSIP call success" >}}
|
||||
|
||||
|
||||
### BulkVS Configuration
|
||||
|
||||
BulkVS provides the phone number and the carrier side of the call path. I can use it to receive calls from the public phone network and send outbound calls from FreePBX.
|
||||
|
||||
- Create BulkVS account
|
||||
- Fund account for testing: $25 minimum
|
||||
- Purchase a DID
|
||||
- **Inbound** > **DIDs - Purchase**.
|
||||
- Enable BulkVS services: CNAM, Outbound, Inbound
|
||||
- **Account** > **Service Status**.
|
||||
|
||||
{{< image
|
||||
src="images/bulkvs-service-enable.png"
|
||||
caption="bulkvs services enabled" >}}
|
||||
|
||||
|
||||
BulkVS is configured to know where my PBX lives and how to deliver calls to it. Next, I configure a host:
|
||||
|
||||
- In **Interconnection** > **Hosts**, supply internet-facing IP address of the PBX, e.g. `39.216.21.150`.
|
||||
- To create a Trunk Group, go to **Interconnection** > **Trunk Group - Manage**.
|
||||
- Select **Create** for **SIP Registration Trunk Group**.
|
||||
- **Trunk Group Name**: e.g. `bulkvs-trunk`
|
||||
- **Password**: e.g. `1#0LZWxh*B4ax28q`
|
||||
- **Digit Delivery**: 11 Digits
|
||||
- In **Inbound** > **DIDs - Manage**, select **view** on the phone number. Select the trunk group `bulkvs-trunk` and save changes.
|
||||
|
||||
|
||||
### FreePBX Configuration
|
||||
|
||||
FreePBX is configured with a PJSIP trunk that registers to BulkVS.
|
||||
|
||||
- Trunk name: `bulkvs-trunk`
|
||||
- Type: `PJSIP`
|
||||
- Authentication: BulkVS SIP registration username and password
|
||||
- SIP server: value from the BulkVS portal, e.g. `sip.bulkvs.com`
|
||||
- Registration: enabled with the BulkVS registration settings
|
||||
- Match (Permit): BulkVS signaling IP addresses
|
||||
- Codecs: `ulaw`, `alaw`
|
||||
|
||||
{{< image
|
||||
src="images/freepbx-trunks.png"
|
||||
caption="FreePBX Trunks" >}}
|
||||
|
||||
#### Inbound and Outbound routes
|
||||
|
||||
**Inbound**
|
||||
|
||||
I create an inbound route for the BulkVS DID. Calls to the DID ring at the configured destinations:
|
||||
|
||||
- DID: BulkVS number in 11-digit format
|
||||
- Destination: extension or ring group
|
||||
|
||||
I have configured a ring group containing several extensions. Each extension in the ring group `600` will ring when the configured DID is called.
|
||||
|
||||
{{< image
|
||||
src="images/freepbx-inbound-routes.png"
|
||||
caption="FreePBX Ring Groups" >}}
|
||||
{{< image
|
||||
src="images/freepbx-ring-groups.png"
|
||||
caption="FreePBX Ring Groups" >}}
|
||||
{{< image
|
||||
src="images/freepbx-ring-group-600.png"
|
||||
caption="FreePBX Ring Group 600 Configuration" >}}
|
||||
|
||||
**Outbound**
|
||||
I also configure an outbound route that sends normal phone-number calls through BulkVS:
|
||||
|
||||
- Trunk sequence: `bulkvs-trunk` first
|
||||
- Match pattern: `1NXXNXXXXXX`
|
||||
- Match pattern: `NXXNXXXXXX`
|
||||
- Prepend for `NXXNXXXXXX`: `1`
|
||||
|
||||
🎉 **It works!**
|
||||
|
||||
✅ Done.
|
||||
@@ -1,66 +0,0 @@
|
||||
+++
|
||||
categories = ["plants"]
|
||||
tags = ["houseplants", "repotting", "clay-pots"]
|
||||
date = 2026-05-31T10:00:00-06:00
|
||||
description = "Repotting a ficus, calathea, money tree, and orchids into clay containers chosen for appearance, drainage, and pest control."
|
||||
draft = false
|
||||
slug = "2026-05-31-plant-repot"
|
||||
title = "🪴 Plant Repotting"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
The plants need new pots more fitting of their beauty and vitality not some ugly slimy plastic. I prefer clay: better aesthetics, drier soil and easier to control pests.
|
||||
|
||||
## Ficus Audrey
|
||||
|
||||
For two years this tree did nothing. No growth, no changes, nothing at all. So we attempted to induce some kind of growth by snipping off a small length of growth at the top: the **apical meristem**. This shortly prompted the smallest of growths to develop on the node just beneath the cut, barely detectable, a small green nub.
|
||||
|
||||
{{< image
|
||||
src="images/ficus-audrey-new-growth.jpg"
|
||||
caption="BEFORE: new growth, very early" >}}
|
||||
|
||||
And then, once again, for months it was still, unchanging...until a few weeks ago when we noticed the roots had begun to reach out beneath the pot, winding around into the saucers of nearby planters. And the tiny green nub had elongated into a very small proto-leaf. This is all very unremarkable as far as plants go, just part of the normal course of things, but it is the first time since acquiring the plant that it has shown any obvious signs of actually being alive.
|
||||
|
||||
{{< image
|
||||
src="images/ficus-audrey-new-leaf.JPG"
|
||||
caption="AFTER: growth developed into a new leaf" >}}
|
||||
|
||||
These signs of expanding life call for an expanded container.
|
||||
|
||||
{{< image
|
||||
src="images/ficus-audrey-new-pot.JPG"
|
||||
caption="ficus audrey repotted" >}}
|
||||
|
||||
|
||||
## Pin-stripe calathea
|
||||
|
||||
This plant developed a severe spider-mite infestation. It died slowly despite heroic interventions. The ficus audrey will move into its pot.
|
||||
|
||||
{{< image
|
||||
src="images/pinstripe-calathea-dying.jpg"
|
||||
caption="Pinstripe calathea dying, RIP. goodbye" >}}
|
||||
|
||||
## Money tree
|
||||
|
||||
This plant came potted in a small plastic pot. It needs to match or the whole thing is SHOT and the aesthetics are TRASH. Upgrade.
|
||||
|
||||
{{< image
|
||||
src="images/money-plant-repot-complete.JPG"
|
||||
caption="money plant (and old plastic orchid pots)" >}}
|
||||
|
||||
## Orchids
|
||||
|
||||
These orchids were gifted to us and came packaged in very wet soggy media, which is fine for transporting the orchids long distances to keep them alive, but will rot the roots with time. Some roots were rotted already and had to be pruned. We repotted in orchid bark which had to be soaked first. Also, we upgraded their pot from plastic to a clay pot with vents.
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/orchid-bark-soak.JPG"
|
||||
caption="Soaking orchid bark" >}}
|
||||
|
||||
{{< image
|
||||
src="images/orchid-root-pruning.JPG"
|
||||
caption="prune rotted roots" >}}
|
||||
|
||||
{{< image
|
||||
src="images/orchids-repot-complete.JPG"
|
||||
caption="orchids in new pots" >}}
|
||||
@@ -1,26 +0,0 @@
|
||||
+++
|
||||
categories = ["vehicle"]
|
||||
tags = ["maintenance", "repair", "evap"]
|
||||
date = 2026-07-06T20:00:00Z
|
||||
description = "Replacing a stuck-open EVAP purge valve after rough idling and difficult starts following refueling."
|
||||
draft = false
|
||||
slug = "2026-07-06-replacing-purge-valve"
|
||||
title = "💨 Replacing a Stuck-Open Purge Valve"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
This car is a money pit to be sure. I have replaced this exact component once before, under exactly the same circumstances. The same set of symtoms: rough idle, RPM guage bouncing around a little, stumbles and shakes like a pathetic little wet rat upon starting for the first time after refueling.
|
||||
|
||||
The part functioned for a few years, and here we are again. Luckily it is stupid easy to replace, and apparently dirt cheap on Amazon. I paid something like 50 USD for this part only for it to fail after a couple of years. I shudder to think how long a $15 part will last...
|
||||
|
||||
{{< image
|
||||
src="images/amazon-part.png"
|
||||
caption="A very inexpensive car part" >}}
|
||||
|
||||
I replaced it, let us see how long it lasts...
|
||||
|
||||
{{< image
|
||||
src="images/replacement-part-under-hood.jpg"
|
||||
caption="Visually identical – promising..." >}}
|
||||
|
||||
Done.
|
||||
@@ -1,136 +0,0 @@
|
||||
+++
|
||||
categories = ["build","software"]
|
||||
tags = ["ansible", "docker", "devops", "self-hosting"]
|
||||
date = 2026-07-07T10:00:00-05:00
|
||||
description = "Turning a growing collection of self-hosted services into reproducible home infrastructure managed with Git, Ansible, and Docker Compose."
|
||||
draft = false
|
||||
slug = "2026-07-07-home-ops"
|
||||
title = "🏗️ Home Ops Upgrades"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
As the number of self-hosted services I use regularly has grown, managing them by hand began to feel wrong, and occasionally annoying and tedious. It ends now!
|
||||
|
||||
{{< image
|
||||
src="images/portainer-container-dashboard.png"
|
||||
caption="Container dashboard - many apps" >}}
|
||||
|
||||
In the beginning, my workflow was pretty sloppy since much of the deployment/management depended on me remembering to restart a container or update a version, manually copy a secret here and there. Things like this. Totally maintainable, sustainable with some effort, but it is not *the way*. And to some degree these manual interventions will always be necessary, but it will serve me well to reduce them and automate them away as much as reasonable to help these apps live far into the future with little fuss. Many of these apps have proven their usefulness to me over many years, so I have begun to take their management more seriously, slowly taking steps toward git-ops patterns and best practices. Before: scattered `.env` files. Flat directory of compose files. No documentation. Manually copying secrets by hand. Manually rebuilding host dependencies by hand. Now: a single command issued by a single click of a button (maybe I am overstating the simplicity here...nevertheless) will handle mostly everything I care about. Host setup, Compose projects, rendered secrets, state directories, validation, and deployment are all described in one place. Easy!
|
||||
|
||||
## Git Repo as Source of Truth
|
||||
|
||||
I am trying to move my app hosting toward git-ops patterns, where the git repo is the "source of truth" which describes the desired state of my home apps/services etc. The repo checkout itself should stay disposable. This will be useful to me because changes become reviewable and repeatable. If I move a service to a different host, for example, or a port changes, or a secret is added, or a stack is disabled, that change will be obvious in git logs, and it will be easily deployable since everything is in one place.
|
||||
|
||||
Prior to this overhaul, I had one repo for each host. I decided to combine all host config to a single repo for simplicity, since managing the deployment logic across two and possibly many more repos would sort of defeat the original purpose of making things simpler and more robust. Configuration would drift apart, and all of my annoyances under the previous 'workflow' would return.
|
||||
|
||||
## Ansible
|
||||
|
||||
The main tool making the magic happen is Ansible. This is sort of the 'infrastructure-as-code' layer, configuring the OS host environment in all the ways necessary to run the apps. It installs packages, configures Docker, creates state directories, manages the deploy user, mounts storage, renders secrets, and starts containers, stopping short of actually provisioning the VM/host itself (Maybe coming soon).
|
||||
|
||||
{{< image
|
||||
src="images/actions-deploy.png"
|
||||
caption="Gitea Actions deploy workflow" >}}
|
||||
|
||||
|
||||
### Inventory
|
||||
|
||||
I used Ansible inventory to map and name the hosts, set the connection details, and group machines so the same playbooks behave differently for each host. In the new `home-ops` repo, inventory lives in `ansible/inventories/production/`. Shared defaults live in `group_vars/all.yml`, host-specific settings live in, for example `group_vars/edge.yml`,`group_vars/server.yml`. This is how Ansible can decide which Compose projects run on the `edge` host and which run on the `server` host.
|
||||
|
||||
### Roles
|
||||
|
||||
I am using roles to keep the playbooks organized. There is some reuse between each playbook since the host env is basically the same. The playbook will define the order of operations, while the roles will be the steps of host setup:
|
||||
|
||||
- related work stays together
|
||||
- shared setup can be reused across host groups
|
||||
- `edge`-only and `server`-only behavior stay separated
|
||||
- common tasks do not need to be copied between playbooks
|
||||
|
||||
In this repo:
|
||||
- `common` prepares the basic host environment
|
||||
- `docker` installs Docker and Compose
|
||||
- `state` creates runtime state directories
|
||||
- `storage-mounts` manages NAS mounts
|
||||
- `edge-host` handles edge-specific host setup
|
||||
- `step-ca` syncs private CA trust
|
||||
- `preflight` checks required rendered files
|
||||
- `compose-projects` runs the enabled Compose apps
|
||||
|
||||
### Compose Projects
|
||||
|
||||
Each app "stack" is a Compose project under `stacks/apps/` and `stacks/edge/`. A stack can include more than one container, since many apps depend on supporting services/containers, e.g. web container, a database, a cache, a worker.
|
||||
|
||||
### State Directories
|
||||
|
||||
Ansible creates `state_root` and app-specific state directories. Compose uses `${STATE_ROOT:-/opt/home-ops-state}`.
|
||||
|
||||
State lives outside the repo because as I mention above, the repo checkout should stay disposable. A deploy can replace or update `/opt/home-ops` without deleting databases, uploads, generated config, caches, or other runtime data. The repo describes the desired configuration; `/opt/home-ops-state` holds the mutable state created by running services.
|
||||
|
||||
### Deployment Paths
|
||||
|
||||
Production deployments can run from Gitea Actions or manually from a control machine. A control machine is a machine running Ansible which connects to the target hosts over SSH and applies the playbooks there. Manual Ansible runs are useful for development, testing, and recovery when the Gitea workflow is unavailable. For example, from a prepared control machine:
|
||||
|
||||
```sh
|
||||
ansible-playbook playbooks/server.yml
|
||||
```
|
||||
|
||||
But the simplest/easiest way to deploy is to click a button in a web UI. My `Deploy` workflow supports `all`, `edge`, and `server` targets.
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/deploy-workflow.png"
|
||||
caption="Deploy workflow in Gitea" >}}
|
||||
|
||||
### Data
|
||||
|
||||
Large data which ought to live on my NAS are bind-mounted under `/mnt/data` and `/mnt/backup`. Ansible can create mount point directories, but NAS ownership and ACLs are managed on the NAS side. The point is to keep large media and backup data out of the repo host filesystems. They take up a lot of space and do not belong on the fast, limited storage on container hosts.
|
||||
|
||||
Some services use Docker volumes for persistent state, for convenience, since I am happy to let Docker manage the data and do not much care about these details. For long-lived or state that I care about, I want to use `/opt/home-ops-state` which is easier to reason about because it has an explicit path and can neatly be included in a backup/restore operation.
|
||||
|
||||
### Secrets
|
||||
|
||||
Secrets are stored in SOPS and rendered before Compose starts. Plaintext `.env` files, private keys, and decrypted secrets are not committed to the repo. Keeping encrypted secrets in the repo is useful because the secret manifest can live next to the configuration that needs it. I can see that a service requires a `.env` file or mounted secret file without committing the plaintext values. Secret changes also get version history like any other infra change and the actual secret values remain encrypted. This is much preferred to keeping secrets as random files on one host and manually copying them onto others. The repo can describe which secret files must exist, where they should be rendered, and which services consume them. SOPS handles the encryption and Ansible handles rendering the files during deploy.
|
||||
|
||||
#### App Env Secrets
|
||||
|
||||
Most app secrets are rendered from the same SOPS `secret_files` list. Ansible decrypts the SOPS file on the control machine, then writes each secret file to its destination before Compose starts. The app secrets mentioned are mostly in the form of a per-service `.env` files that live next to the app's `compose.yaml`. Compose reads that file and uses the values for environment variables. A couple of other apps use the same idea, but instead require app-specific secret files rather than `.env` files. These are still SOPS-rendered secrets, but the destination is a mounted runtime file rather than a `.env` file.
|
||||
|
||||
#### Deploy Secrets
|
||||
|
||||
Deploy secrets are not consumed by the apps themselves but instead are used by the deployment system so Gitea Actions or a control machine can connect to the hosts and render production secrets.
|
||||
|
||||
For example, the Gitea deploy workflow uses secrets:
|
||||
- `DEPLOY_SSH_KEY`: allows the action runner to SSH to the inventory hosts as the `deploy` user
|
||||
- `SOPS_AGE_KEY`: decrypts `secrets/sops/production.sops.yml`
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/actions-secrets.png"
|
||||
caption="Gitea Actions secrets" >}}
|
||||
|
||||
|
||||
This separation is useful because deploy credentials have a different lifecycle from app credentials. Rotating a deploy key should not require changing app `.env` files, and changing an app password should not affect the deployment path.
|
||||
|
||||
### Deploy User
|
||||
|
||||
The `deploy` user is used for production Ansible runs and gets passwordless sudo through a dedicated sudoers file. This gives the ansible automations its own dedicated identity instead of using my personal login. There is a playbook for creating the user, installing the committed public key, and granting passwordless sudo.
|
||||
|
||||
|
||||
## Renovate
|
||||
|
||||
Renovate watches my Docker images and opens PRs when updates are available, but only if I select the proposed update in the Dependency Dashboard. This dashboard is a Gitea Issue the `renovate-bot` user creates and manages, and it is used by Renovate to decide which images should be updated by way of a pull request. Since I do not want every Docker image involved yet I limit Renovate to only the Compose stacks in my whitelist.
|
||||
|
||||
{{< image
|
||||
src="images/renovate.png"
|
||||
caption="Renovate dependency dashboard" >}}
|
||||
|
||||
The Renovate workflow can run on a schedule or manually from Gitea Actions. Renovate proposes updates which I can review and deploy manually (or automatically if I want, I suppose). It is highly configurable, but I have kept my own configuration simple and mostly manual for now.
|
||||
|
||||
This workflow requires some secrets:
|
||||
- `RENOVATE_TOKEN` Gitea access token so Renovate can create issues and PRs.
|
||||
- `RENOVATE_GITHUB_COM_TOKEN` used by Renovate to access GitHub-hosted release notes and metadata.
|
||||
|
||||
## Future Changes
|
||||
|
||||
Future changes will mostly be about making hidden state less hidden, which means moving important Docker volumes into explicit state paths. I may also revisit secrets management approach later if I want to experiment with using a secret manager e.g. HashiCorp Vault. It is still possible to go a layer deeper than the apps, into the true infrastructure layer with Terraform or OpenTofu. Lots of possibilities, but for now, it is good enough.
|
||||
|
||||
✅ Done.
|
||||
@@ -1,5 +0,0 @@
|
||||
images:
|
||||
- src: "images/flower-gallery/flowers-05-15.JPG"
|
||||
- src: "images/flower-gallery/flowers-05-28.JPG"
|
||||
- src: "images/flower-gallery/flowers-06-11.JPG"
|
||||
- src: "images/flower-gallery/flowers-07-09.JPG"
|
||||
@@ -1,4 +0,0 @@
|
||||
images:
|
||||
- src: "images/herb-gallery/herb-garden-05-28.JPG"
|
||||
- src: "images/herb-gallery/herb-garden-06-11.JPG"
|
||||
- src: "images/herb-gallery/herb-garden-06-22.JPG"
|
||||
@@ -1,40 +0,0 @@
|
||||
+++
|
||||
categories = ["plants"]
|
||||
tags = ["gardening", "herbs", "flowers"]
|
||||
date = 2026-07-09T10:00:00-05:00
|
||||
description = "A photo-rich tour of the 2026 herb garden, its flowers, and the effects of heavy rain on the season's plants."
|
||||
draft = false
|
||||
slug = "2026-07-09-herb-garden"
|
||||
title = "🌿 2026 Herb Garden"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
All the classic herbs this year. Everything did O.K. except cilantro (or oregano? I do not know) since it rained heavily for a couple weeks and the roof drainage repeatedly dumped a bajillion gallons of water directly on top of this section all at once, drowning them...so not a great yield. It is no great loss if cilantro.
|
||||
|
||||
As a child I was occasionally served a certain type of frozen pizza (Totino's Party Pizza, perhaps?) as a special treat, which to me tasted faintly of soap. I would tell my mother this each time, she would taste it for herself insisting that she rinsed her hands of all traces of soap this time, then report tasting nothing unusual. We all shrug and move on, repeat.
|
||||
|
||||
Much later it occured to me I had the cilantro taste-like-soap gene. Although are we sure it does not taste like "soap" to everyone and we are all just talking past one another? The phenomenology is weak here. Picture an apple, does it have color? Does it shine? Can you rotate it in 6 dimensional hyper-space? What is it like to be a bat? Man, I don't know.
|
||||
|
||||
|
||||
## Herbs
|
||||
Anyway, the herbs:
|
||||
- parsley
|
||||
- dill
|
||||
- cilantro
|
||||
- basil
|
||||
- chives
|
||||
- green onion
|
||||
|
||||
|
||||
{{< gallery name="herb-gallery" >}}
|
||||
|
||||
We use basically all of these herbs in stuffed grape leaves. Basil on the pizza and spaghetti. Garnish this-and-that with green onion and chives.
|
||||
|
||||
## Flowers
|
||||
Bonus flower pot this year, assorted flowers:
|
||||
|
||||
{{< gallery name="flower-gallery" >}}
|
||||
|
||||
## Misc
|
||||
|
||||
{{< gallery name="misc-gallery" >}}
|
||||
@@ -1,6 +0,0 @@
|
||||
images:
|
||||
- src: "images/herb-harvest.JPG"
|
||||
- src: "images/hibiscus-flowers.JPG"
|
||||
- src: "images/pink-flower-close.JPG"
|
||||
- src: "images/birds-flowers.JPG"
|
||||
- src: "images/flowers-bloom.JPG"
|
||||
@@ -1,186 +0,0 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["voice-agent","freepbx","livekit","sip","ollama","openwebui","speech-to-text","text-to-speech"]
|
||||
date = 2026-08-03T10:00:00-05:00
|
||||
description = "Building a self-hosted telephone voice agent with FreePBX, LiveKit, streaming speech recognition, local language models, and retrieval-backed specialist voices."
|
||||
draft = false
|
||||
slug = "2026-08-03-local-voice-agent"
|
||||
title = "☎️ Local Voice Agent"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
In a [previous post]({{< relref "posts/2026-03-01-voip" >}}), I built a small telephone system with FreePBX and a low-cost SIP carrier, BulkVS. This gave me a real phone number, extensions, softphones, a ring group, and voicemail. This would serve as the perfect entry point for a self-hosted voice agent.
|
||||
|
||||
I have configured the system such that when my phone number is dialed, a voice describes a telephone menu where the caller can dial a selection:
|
||||
- `1` to speak with an AI bot about me,
|
||||
- `2` to leave voicemail, or
|
||||
- `3` to try to reach me (ring my ring group, which are each of my IP phone extensions)
|
||||
|
||||
This is still a hobby project. It is not a customer-service platform, or a production-ready architecture. It is, however, a complete working system and a useful experiment in real-time audio, local AI, retrieval, and telephone routing.
|
||||
|
||||
## What I Wanted
|
||||
|
||||
I wanted the system to meet a few requirements:
|
||||
|
||||
- Keep the telephone system in existing FreePBX.
|
||||
- Run speech recognition, the language model, and speech synthesis on my own modest hardware.
|
||||
- Answer questions only from information that I intentionally published.
|
||||
- Route different subjects to agents with different voices and knowledge bases.
|
||||
- Preserve normal telephone features such as voicemail and a ring group.
|
||||
|
||||
I did not want the language model to control the initial phone menu. The first decision is deterministic: the caller presses a number, and the router follows a fixed route. The AI becomes involved only after the caller selects the conversational option.
|
||||
|
||||
## Architecture
|
||||
|
||||
The more or less complete path:
|
||||
|
||||
```text
|
||||
Public telephone network
|
||||
↓
|
||||
SIP carrier (BulkVS)
|
||||
↓
|
||||
FreePBX / Asterisk
|
||||
↓
|
||||
LiveKit SIP
|
||||
↓
|
||||
LiveKit room
|
||||
↓
|
||||
Deterministic telephone router
|
||||
↓
|
||||
Python knowledge agent
|
||||
├── Silero voice activity detection
|
||||
├── sherpa-onnx + NVIDIA Nemotron streaming speech recognition
|
||||
├── Open WebUI retrieval and model management
|
||||
├── Ollama + Granite language model
|
||||
└── Kokoro speech synthesis
|
||||
↓
|
||||
The same path back to the caller
|
||||
```
|
||||
|
||||
FreePBX remains the public telephone system. It owns the inbound route, voicemail, the ring group, and the connection to the SIP carrier. The AI services are on one of my local application hosts with access to my GPU. FreePBX sends only the voice-agent route to a private LiveKit SIP trunk, so I do not expose LiveKit's SIP or media ports directly to the internet.
|
||||
|
||||
LiveKit converts the SIP call into a room with participants and audio tracks. A small Python router, which I call the Usher, joins first. It is a deterministic menu application, not a language-model agent: it has no speech recognition, language model, tools, or autonomous routing decisions. It plays fixed menu options and receives telephone keypad events.
|
||||
|
||||
If the caller presses `1`, the telephone router dispatches the knowledge agent into the same room. The system plays a short telephone routing tone to indicate the handoff. The Usher remains in the room without speaking so it can retain the active-call lease, enforce the call-duration limit, and clean up when the caller disconnects. If the caller selects voicemail or my ring group, LiveKit sends the call back to the appropriate FreePBX destination with a SIP transfer.
|
||||
|
||||
## The House Steward and Specialists
|
||||
|
||||
The first conversational profile is the House Steward. Its greeting is deliberately simple, it will ask something simple like "Hello. Question about Nicholas?" This general profile can explain that the caller is speaking with an AI, describe what the service does, and answer broad questions.
|
||||
|
||||
The specialists cover subjects such as bicycling, books, career history, cooking, gardening, movies, music, photography, quotations, self-hosted services, travel, and early life info. This list will probably change. Each profile has its own:
|
||||
|
||||
- instructions
|
||||
- Open WebUI model wrapper
|
||||
- knowledge base
|
||||
- retrieval rules
|
||||
- Kokoro voice
|
||||
|
||||
A separate local classifier reads a completed caller turn and a small amount of recent conversation. It returns one profile and a confidence score. A high-confidence change causes a handoff inside the existing LiveKit session. The current voice announces the transfer, a short telephone routing tone plays, and the new voice gives a short introduction. The room and conversation remain intact.
|
||||
|
||||
## Published Knowledge
|
||||
|
||||
The personal information does not live in the voice-agent image or its prompt. I keep published Markdown and other data files in a separate knowledge repository. Each conversational profile has exactly one dedicated Open WebUI knowledge base. A synchronization workflow copies each published file into the appropriate knowledge bases. The general profile's knowledge base contains all published personal material, while each specialist's knowledge base contains a smaller subject-specific selection. A document can appear in several knowledge bases when it is relevant to several profiles.
|
||||
|
||||
For an ordinary completed turn that does not trigger a handoff, the Python agent makes three requests:
|
||||
|
||||
```text
|
||||
Caller transcript
|
||||
│
|
||||
├── 1. Ask the local Ollama classifier to select a profile
|
||||
│ ↓
|
||||
│ profile + confidence
|
||||
│
|
||||
├── 2. Search the active profile's Open WebUI knowledge base
|
||||
│ ↓
|
||||
│ relevant excerpts
|
||||
│
|
||||
└── 3. Send the question, excerpts, and conversation to the model
|
||||
↓
|
||||
answer text
|
||||
```
|
||||
|
||||
If classification selects another profile with sufficient confidence, the fixed handoff sequence consumes that turn instead of performing retrieval and generating an answer. Otherwise, the agent limits and deduplicates the retrieved excerpts before adding them to the temporary context. The profile prompt tells the model not to invent personal details that are absent from those excerpts. Open WebUI owns the knowledge bases, embeddings, access grants, and named model configuration. Ollama runs the local Granite model.
|
||||
|
||||
## Open WebUI Configuration
|
||||
|
||||
Each conversational profile has a corresponding Open WebUI custom model wrapper attached only to that profile's knowledge base.
|
||||
|
||||
Every wrapper uses `granite4.1:3b` as its Ollama base model, with an 8,192-token context and a 96-token response limit. The wrapper gives the profile a stable model ID and supplies its role-specific system prompt, attached knowledge base, and model parameters. Retrieved evidence is added at request time.
|
||||
|
||||
Access is managed through two Open WebUI groups. The knowledge-synchronization account can write to the knowledge bases, while the voice-agent account receives read access to the managed knowledge bases and model wrappers. For each voice profile, the built-in knowledge tool is enabled and scoped to that profile's single attached knowledge base. Web search, memory, chat history, code execution, terminal access, image generation, notes, tasks, calendars, automations, custom tools, skills, filters, and actions are disabled so the model stays very dumb and safe.
|
||||
|
||||
The active profile selects its matching knowledge base for retrieval. Because the knowledge repository and synchronization workflow form a separate publication boundary, I can update published information without rebuilding the voice-agent container; drafts and private notes are not synchronized.
|
||||
|
||||
The general profile acts as a sort of FAQ for questions about the call itself. It can explain the AI identity, transfers, limits, logging, voicemail, knowledge boundaries, etc.
|
||||
|
||||
## Streaming Speech Recognition
|
||||
|
||||
Speech recognition runs through `sherpa-onnx` with NVIDIA's Nemotron 3.5 streaming model on my NVIDIA GPU.
|
||||
|
||||
A streaming speech recognizer's "final" result does not necessarily mean that the caller's entire conversational turn is finished. It means that a portion of the transcript is stable. A caller can speak a long sentence with a pause and produce several final transcript fragments.
|
||||
|
||||
LiveKit collects those stable fragments into one turn. Two timing thresholds coordinate this (values given here change as I tune to my liking):
|
||||
|
||||
- sherpa-onnx finalizes a stable segment after some time (something like 0.8 seconds) of trailing silence
|
||||
- Silero marks the end of the caller's turn after, say, 1.5 seconds of silence
|
||||
|
||||
The gap gives the final transcript fragment time to reach LiveKit before the turn is committed. LiveKit can therefore combine several final fragments produced during one speaking state and send the complete request to the classifier.
|
||||
|
||||
## Interruptions and Handoffs
|
||||
|
||||
Telephone conversation is mostly open-ended. A caller can interrupt the agent while it is answering, change subjects during model generation, or ask the system to stop.
|
||||
|
||||
The agent tracks a generation number for each caller turn. When the caller starts speaking, it invalidates older work and interrupts active generated speech. Retrieval or model output from an older generation is discarded before it can reach the caller. The words `stop` and `cancel` are also handled as direct cancellation commands.
|
||||
|
||||
Handoffs use a stricter sequence. The current profile finishes its fixed transfer sentence, the system changes the active instructions, model, knowledge base, and voice, and then the receiving profile speaks. These fixed handoff messages are not generated by the language model. This prevents two profiles from speaking over each other or a delayed response from the old profile appearing after the transfer.
|
||||
|
||||
## Public-Call Safeguards
|
||||
|
||||
Connecting any automated service to a public telephone number creates opportunities for accidents and abuse. I added limits before routing the public DID to the agent:
|
||||
|
||||
- one active public AI call at a time;
|
||||
- a maximum of 15 minutes per call;
|
||||
- a maximum of 60 minutes per caller number per UTC day;
|
||||
- caller IDs that parse as valid United States numbers only;
|
||||
- a 60-second inactivity timeout; and
|
||||
- fixed voicemail and ring-group routes outside the language model.
|
||||
|
||||
Redis holds the active-call lease and daily counters. Caller numbers are normalized for validation and hashed for the usage-counter keys. This is caller-ID validation, not a determination of the caller's physical location: caller ID is not authentication and can be spoofed. These controls limit casual abuse, while carrier-side restrictions remain useful defense in depth.
|
||||
|
||||
The service does not save an audio recording of the AI conversation. It does log final caller and agent transcripts, the complete retrieved excerpts, the assembled model-message context, timing data, warnings, and errors. Some diagnostic values are logged in both plain text and base64; base64 is only another representation, not encryption or privacy protection. Logs rotate daily and normally retain seven daily rotations. They are excluded from backup archives. Voicemail is different: when the caller chooses voicemail, FreePBX intentionally records the message and sends the normal voicemail notification.
|
||||
|
||||
## Performance
|
||||
|
||||
The system normally feels conversational, but it is not instant. A typical completed turn contains several distinct delays:
|
||||
|
||||
1. 1.5 seconds of silence to determine that the caller has finished.
|
||||
2. A few hundred milliseconds for topic classification.
|
||||
3. Usually tens of milliseconds for local knowledge retrieval.
|
||||
4. Model time to the first generated token.
|
||||
5. Kokoro time to the first audio frame.
|
||||
|
||||
In recent calls, the complete delay before speech commonly landed around three to five and a half seconds. The largest variable was often text-to-speech startup, which sometimes took more than two seconds. The local Granite model usually began producing text quickly, but a fast model does not make the whole voice pipeline fast.
|
||||
|
||||
I added per-stage metrics because a single "the agent is slow" measurement is not actionable. End-of-turn detection, transcription, classification, retrieval, model generation, and speech synthesis need separate timing records.
|
||||
|
||||
## Current Limitations
|
||||
|
||||
The system is operational, but it is not finished.
|
||||
|
||||
Speech recognition sometimes loses the end of a word. Topic classification can still select the wrong specialist. The small local model can ignore a grounding instruction, invent an unsupported recipe step, or produce an answer that is too long. Things like this. A hard token limit can stop a long answer in the middle of a sentence, which sounds particularly bad on a telephone call.
|
||||
|
||||
There are also two configuration layers: the Python runtime supplies current turn instructions, while Open WebUI supplies named-model configuration and permissions. Those layers must remain aligned.
|
||||
|
||||
## Was This the Simple Way?
|
||||
|
||||
No. A hosted voice-agent API could replace much of this stack. My goal, however, was to connect the phone system I already built to speech and language models running on my own hardware. For that goal, the separation now makes sense:
|
||||
|
||||
- FreePBX owns telephone behavior.
|
||||
- LiveKit owns real-time media and agent sessions.
|
||||
- sherpa-onnx owns streaming transcription.
|
||||
- Open WebUI owns published knowledge and model access.
|
||||
- Ollama owns local language-model inference.
|
||||
- Kokoro owns speech synthesis.
|
||||
- The Python agent owns routing, grounding, handoffs, and cancellation.
|
||||
|
||||
✅ works.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["pizza", "gallery"]
|
||||
date = 2024-10-16T20:27:44Z
|
||||
description = "A gallery collecting homemade American-style pizzas and their mighty-good crusts and toppings."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "american-pizza"
|
||||
title = "🍕 American Pizza"
|
||||
@@ -14,4 +13,5 @@ author = "nicholas"
|
||||
> — **Old man**, passing encounter at Sam's Club
|
||||
|
||||
## Photos of my Pizzas
|
||||
{{< gallery name="american-pizza-gallery" >}}
|
||||
{{< gallerynew name="american-pizza-gallery" >}}
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["reverse-engineer","arbys","sauce"]
|
||||
date = 2025-10-04T13:00:00-05:00
|
||||
description = "Reverse-engineering Arby's berry-free Bronco Berry Sauce from official ingredient and nutrition documents."
|
||||
draft = false
|
||||
slug = "arbys-bronco-berry-sauce"
|
||||
title = "🐎 Arby's Bronco Berry Sauce"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
In this fast food reverse engineering post, I reference two documents:
|
||||
- [Arby's Ingredients](files/arbys-bronco-berry-ingredient-list.pdf)
|
||||
- [Arby's Nutrition](files/arbys-bronco-berry-nutrition.pdf)
|
||||
|
||||
|
||||
## 📋 Ingredient List
|
||||
Surpringly, the sauce contains absolutely no berries. In fact, it looks a lot like a basic **sweet and sour sauce**:
|
||||
|
||||
{{< image
|
||||
src="images/arbys-bronco-berry-sauce-ingredient-list.png"
|
||||
caption="Arby's bronco berry sauce ingredient list" >}}
|
||||
|
||||
```
|
||||
High Fructose Corn Syrup, Water, Bell Pepper, Distilled Vinegar, Modified Corn Starch, Jalapeno Pepper, Vegetable Juice Concentrate (color), Potassium Sorbate and Sodium Benzoate (preservatives), Onion (dehydrated), Salt, Spice, Xanthan Gum, Citric Acid, Acetic Acid.
|
||||
```
|
||||
|
||||
|
||||
Compare this to *La Choy Sweet and Sour Sauce*. Curious....
|
||||
|
||||
{{< image
|
||||
src="images/La-Choy-Sweet-and-Sour-Stir-Fry-Sauce.avif"
|
||||
caption="La Choy Sweet and Sour Sauce" >}}
|
||||
|
||||
|
||||
```
|
||||
Water, Sugar, Distilled Vinegar, Modified Corn Starch, LESS THAN 2% OF: Salt, Pineapple Juice Concentrate, Dried Red Bell Peppers, Oleoresin Paprika.
|
||||
```
|
||||
|
||||
Just look at the overlap. It's essence is a sweet and sour sauce:
|
||||
|
||||
| Ingredient | Arby's Bronco Berry | La Choy Sweet and Sour Sauce |
|
||||
|---|---|---|
|
||||
| Water | ✅ | ✅ |
|
||||
| Distilled Vinegar | ✅ | ✅ |
|
||||
| Modified Corn Starch | ✅ | ✅ |
|
||||
| Salt | ✅ | ✅ |
|
||||
| HFCS / Sugar | ✅ | ✅ |
|
||||
| Bell Pepper | ✅ | ✅ |
|
||||
| Jalapeno Pepper | ✅ | |
|
||||
| Pineapple Juice Concentrate | ✅ | |
|
||||
| Onion (dehydrated) | ✅ | |
|
||||
| Xanthan Gum | ✅ | |
|
||||
| Citric Acid | ✅ | |
|
||||
| Acetic Acid | ✅ | |
|
||||
| Spice / Oleoresin Paprika | ✅ | ✅ |
|
||||
|
||||
|
||||
✅ Good enough.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["travel"]
|
||||
tags = ["baltimore", "johns-hopkins", "photo-gallery"]
|
||||
date = 2025-06-07T12:09:00-06:00
|
||||
description = "Two weeks in Baltimore for Johns Hopkins coursework, with a stay at Residence Inn and walks around Fells Point."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "baltimore"
|
||||
title = "🏫 Baltimore, MD"
|
||||
@@ -29,4 +28,4 @@ Bailey is enrolled in a Master's program at Johns Hopkins School of Public Healt
|
||||
loop="true" >}}
|
||||
|
||||
## Photo Gallery
|
||||
{{< gallery name="baltimore-gallery" >}}
|
||||
{{< gallerynew name="baltimore-gallery" >}}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["pie", "banana", "caramel"]
|
||||
date = 2025-03-01T06:27:00-06:00
|
||||
description = "Turning leftover pie crust and caramel into an improvised banoffee pie with bananas and cream."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "banoffee-pie"
|
||||
title = "🍌 Banoffee Pie"
|
||||
@@ -11,7 +10,7 @@ author = "nicholas"
|
||||
|
||||
We made a pumpkin caramel pie to bring for a family dinner (not pictured, strangely). Enough pie crust remained to line a shallow pizza pan, which I baked into a crust before I knew what it would become. We also had a bit of caramel leftover. After a little time considering what ingredients we had, Bailey figured we could make a decent banoffee pie. It is a beautiful accident that she had been wanting to make this after seeing it on *The Great British Bake Off*, and luckier still that we had bananas on hand.
|
||||
|
||||
{{< gallery name="banoffee-pie-gallery" >}}
|
||||
{{< gallerynew name="banoffee-pie-gallery" >}}
|
||||
|
||||
## Recipe
|
||||
This is a lazy recipe because the food was assembled ad-hoc using leftovers of unknown measurements as is the case for many kinds of pies. I use the examples of the quiche and pie to make my point: they serve as the uniting force that brings together really any ingredient in any amount that you happen to have lying around. Details unimportant.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["build"]
|
||||
tags = ["bicycle"]
|
||||
date = 2025-07-04T19:00:00-05:00
|
||||
description = "Replacing unreliable hydraulic brakes on a Trek FX 2 with new front and rear assemblies, then documenting the installation and bike specifications."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "bicycle-brakes-replacement"
|
||||
title = "🚲 Bicycle Brakes Replacement"
|
||||
@@ -131,3 +131,4 @@ My final solution involved inserting a metal coat hanger wire into the hydraulic
|
||||
- Weights listed are based on pre-production painted frames and may vary in final production.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
+++
|
||||
categories = ["build"]
|
||||
tags = ["bicycle"]
|
||||
date = 2025-08-12T12:15:00-05:00
|
||||
description = "Building a handlebar-mounted wide-beam bicycle headlamp with a weather-resistant battery box, controls, and external power."
|
||||
date = 2025-08-07T08:00:00-05:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "bicycle-headlamp"
|
||||
title = "🔦 Bicycle Battery Box and Headlamp"
|
||||
@@ -14,7 +14,7 @@ Nighttime bicycle rides in the summer are ideal:
|
||||
- 🚘 little traffic
|
||||
- 🦝 nocturnal wildlife
|
||||
|
||||
But it is not easy to see in the dark, so I bring a headlamp and *okay, fine, this works*. With my current headlamp I can see and be seen just fine. But there are some limitations. I see wonderfully directly ahead of me in the narrow cone of light provided, but at the edges of this light cone I see practically nothing at all, as though I am looking through a paper towel roll. The beam pattern is optimized for riding in a straight line, with little regard for what lies in the periphery. **I would like a greater field of view, so I will need a lamp with a wide beam pattern.**
|
||||
But it is not easy to see in the dark, so I bring a headlamp and okay, fine, this works. With my current head lamp I can see and be seen just fine. But there are some limitations. I see wonderfully directly ahead of me in the narrow cone of light provided, but at the edges of this light cone I see practically nothing at all, as though I am looking through a paper towel roll. The beam pattern is optimized for riding in a straight line, with little regard for what lies in the periphery. I would like a greater field of view, so I will need a lamp with a wide beam pattern.
|
||||
|
||||
## 🔦 Headlamp
|
||||
I start by selecting the headlamp. This is the most important component, and everything will revolve around it. My selection criteria is simple. The light must have these features:
|
||||
@@ -36,20 +36,24 @@ I had to settle for the **OPL5 Motorcycle LED Headlamp**:
|
||||
| Brightness | 3000 lm |
|
||||
| Color Temperature | 6500K (Cool White) |
|
||||
|
||||
✅ **This will have to do**.
|
||||
|
||||
|
||||
{{< image src="images/opl5-motorcycle-led-headlamp.png" >}}
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/beam-pattern.JPG"
|
||||
caption="OPL5 Beam against the wall" >}}
|
||||
## 🔋 Battery Sizing
|
||||
Since the headlamp consumes 48 W, I need a battery that can sustain this load for a couple of hours without totally draining the battery. I can see how long a given battery will endure.
|
||||
|
||||
Notice the sharp beam cutoff at the top. This is what allows me to have a very bright headlamp while also not blinding oncoming traffic. Also, the beam gradient at the bottom transitions from very bright to very dim, in a manner that maximizes usable light. It concentrates maximum light near the upper cutoff where it will disperse across a large surface area at long distances, while minimizing output in the near and mid field, where the illuminated surface area should be relatively small.
|
||||
- $t$ = run time (hr)
|
||||
- $E_\text{battery}$ = battery energy (Wh)
|
||||
- $p_\text{load}$ = LED headlamp power (W)
|
||||
|
||||
✅ **This will do**.
|
||||
$$
|
||||
t = \frac{12.8 \text{ V} \cdot 16 \text{ Ah}}{48 \text{ W}} = 4.27 \text{ hr}
|
||||
$$
|
||||
|
||||
---
|
||||
✅ **This will work**. It is a comfortable, if not excessive, amount of energy for the headlamp.
|
||||
|
||||
## 🧰 Case
|
||||
I selected a cheap weatherproof case from *Harbor Freight Tools*. It will be large enough to accommodate many sizes of batteries, since battery packs of the nature I seek are almost always composed of a bunch of even smaller batteries, typically the 18650 type batteries. The dimensions of this battery type are also its namesake: **18 mm dia. × 65 mm l**.
|
||||
@@ -63,40 +67,24 @@ I mention all of this because the height dimension of the battery packs are almo
|
||||
|
||||
{{< image src="images/apache-1800-case.png" >}}
|
||||
|
||||
---
|
||||
|
||||
## 🔋 Battery
|
||||
Since the headlamp consumes 48 W, I need a battery that can sustain this load for a couple of hours without totally draining the battery. I can see how long a given battery will endure.
|
||||
|
||||
- $t$ = run time (hr)
|
||||
- $E_\text{battery}$ = battery energy (Wh)
|
||||
- $P_\text{load}$ = LED headlamp power (W)
|
||||
|
||||
$$
|
||||
t = \frac{12.8 \text{ V} \cdot 16 \text{ Ah}}{48 \text{ W}} = 4.27 \text{ hr}
|
||||
$$
|
||||
|
||||
I selected a cheap, unremarkable 12 V battery with a capacity of 16Ah.
|
||||
|
||||
✅ This will be fine.
|
||||
|
||||
{{< image src="images/nermak-12v-16Ah-LiFePO4.png" >}}
|
||||
|
||||
|
||||
✅ **This will work**. It is a comfortable, if not excessive, amount of energy for the headlamp.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Switches and Ports
|
||||
I splurged on a nice weatherproof SPST switch and mounting bracket.
|
||||
|
||||
{{< image src="images/bss-contura-iii-spst.png" >}}
|
||||
{{< image src="images/bss-contura-iii-mounting-bracket.png" >}}
|
||||
|
||||
The port that will feed power to the headlamp power is some kind of 'SAE' connector, which as far as I can tell do not actually comply with any official standard. They are used for a lot of things in weird cheap Chinese electronics, and I could have selected a better connector. I selected this mostly because it matches the connector on my battery charger and it is cheap and ubiquitous. It will be adequate.
|
||||
The port that will feed power to the headlamp power is some kind of 'SAE' connector, which as far as I can tell do not actually comply with any official standard. They are used for a lot of things in weird cheap Chinese electronics, and I could have selected a better connector. I selected this mostly because it matches the connector on my battery charger and it is cheap and ubiquitous.
|
||||
|
||||
{{< image src="images/SAE-connector-socket.jpg" >}}
|
||||
|
||||
---
|
||||
|
||||
## 🔩 Parts List
|
||||
| Part |
|
||||
|-|
|
||||
@@ -106,26 +94,9 @@ The port that will feed power to the headlamp power is some kind of 'SAE' connec
|
||||
| SAE Power Socket Port |
|
||||
| Inline Blade Fuse Holder + 5 A Fuse |
|
||||
| Blue Sea Systems Contura III OFF-ON SPST Switch + Mounting Panel |
|
||||
| Double-sided adhesive tape |
|
||||
| Spade connectors |
|
||||
| Shrink tube |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Assembly
|
||||
Now for final assembly. This is all very simple and I ran into no issues.
|
||||
|
||||
- Applied double-sided adhesive tape to fasten the battery enclosure to the box
|
||||
- Soldered on spade connectors to wires between switch and battery
|
||||
- Installed inline fuse
|
||||
- Sealed the power port and switch mounting holes with silicone
|
||||
- Removed the battery enclosure lid since the battery terminals were ≈ 1 mm proud of the battery box lid. The battery box would still close, but it was straining the plastic and the weather sealing would be compromised.
|
||||
|
||||
{{< image
|
||||
src="images/battery-box-open.JPG"
|
||||
caption="Battery box inside" >}}
|
||||
|
||||
Here is a **wiring diagram** of the circuit.
|
||||
All that remains is to do final assembly. This is all very simple and I ran into no issues. Here is a **wiring diagram** of the circuit.
|
||||
|
||||
{{< image src="images/bicycle-headlamp-wiring-diagram.svg" >}}
|
||||
|
||||
@@ -136,33 +107,17 @@ Here is a **wiring diagram** of the circuit.
|
||||
| SW1 | SPST Switch |
|
||||
| LED1 | 48 W |
|
||||
|
||||
- Applied double-sided adhesive tape to fasten the battery enclosure to the box
|
||||
- Soldered on spade connectors to wires between switch and battery
|
||||
- Installed inline fuse
|
||||
- Sealed the power port and switch mounting holes with silicone
|
||||
- Removed the battery enclosure lid since the battery terminals were ≈ 1 mm proud of the battery box lid. The battery box would still close, but it was straining the plastic and the weather sealing would be compromised.
|
||||
|
||||
{{< image
|
||||
src="images/battery-box-power-port.JPG"
|
||||
caption="Battery box power port" >}}
|
||||
I strap the battery box to the rear cargo rack with hook and loop straps.
|
||||
|
||||
{{< image
|
||||
src="images/battery-box-switch.JPG"
|
||||
caption="Battery box switch" >}}
|
||||
That is all.
|
||||
|
||||
{{< image
|
||||
src="images/battery-box-mounted.JPG"
|
||||
caption="Battery box fastened to the rear cargo rack with hook and loop straps" >}}
|
||||
|
||||
{{< image
|
||||
src="images/headlamp-mounted.JPG"
|
||||
caption="Headlamp on handlebars" >}}
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🚴 Test Drive
|
||||
All that remains is the test drive.
|
||||
{{< image src="images/headlamp-night-test-drive.jpg" >}}
|
||||
|
||||
{{< image
|
||||
src="images/headlamp-night-test-drive.jpg"
|
||||
caption="Wide beam width" >}}
|
||||
|
||||
🎉 **It works!**
|
||||
|
||||
✅ Done.
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["build"]
|
||||
tags = ["bicycle", "diy", "washing"]
|
||||
date = 2024-10-16T20:27:44Z
|
||||
description = "Building a portable bicycle washer from a small keg, bike pump, hose, and spray nozzle for apartment-friendly cleaning."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "bicycle-pump-sprayer"
|
||||
title = "🚲 Bicycle Washing Contraption"
|
||||
@@ -83,4 +82,4 @@ The resulting contraption is a modular, robust, lightweight and small package. I
|
||||
src="images/full-assembly-packed.jpg"
|
||||
caption="Keg pump sprayer ready to go" >}}
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["recipe", "meal-prep", "reverse-engineer", "taco-bell"]
|
||||
date = 2024-04-27T05:12:13Z
|
||||
description = "A scalable method for batch-making freezer burritos with precisely portioned tortillas, beans, beef, rice, and cheese."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "burrito"
|
||||
title = "🌯 Burritos in Bulk"
|
||||
@@ -62,3 +61,4 @@ Reheat from frozen takes ~45" @ 450 F convection. Wrapped tightly in aluminum fo
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["vehicle"]
|
||||
tags = ["maintenance", "repair", "brakes"]
|
||||
date = 2024-10-01T02:49:01Z
|
||||
description = "Planning a safe DIY replacement of the vehicle's brake pads and rotors while working with the wheels removed."
|
||||
description = ""
|
||||
draft = true
|
||||
slug = "brake-pad-replacement"
|
||||
title = "🔧 Brake Pad + Rotor Replacement"
|
||||
@@ -28,4 +27,4 @@ The process is simple and I have not bothered to outline it yet. That is all I k
|
||||
|
||||
## Replacing Brake Rotors
|
||||
|
||||
The process for removing and replacing rotors is not straightforward! Stay tuned.
|
||||
The process for removing and replacing rotors is not straightforward! Stay tuned.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["travel"]
|
||||
tags = ["washington-dc", "new-york-city", "photo-gallery"]
|
||||
date = 2024-12-29T00:00:00Z
|
||||
description = "A photo journal from a trip to Washington, D.C. for a public health summit followed by a stop in New York City."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "dc-nyc"
|
||||
title = "🏛️ Washington, D.C & 🗽New York City, NY"
|
||||
@@ -14,4 +13,4 @@ We went to Washington, D.C. for the [Bloomberg Public Health Summit](https://ame
|
||||
|
||||
|
||||
## Photos
|
||||
{{< gallery name="dcnyc" >}}
|
||||
{{< gallerynew name="dcnyc" >}}
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["food"]
|
||||
tags = ["reverse-engineer","dominos"]
|
||||
date = 2024-10-22T21:26:10Z
|
||||
description = "Documenting an attempt to reverse-engineer the garlic oil blend brushed onto Domino's pizza crust."
|
||||
description = ""
|
||||
draft = true
|
||||
slug = "dominos-garlic-oil-blend"
|
||||
title = "🧄 Domino's Garlic Oil Blend"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["food"]
|
||||
tags = ["reverse-engineer","dominos","pizza"]
|
||||
date = 2025-07-24T16:00:00-05:00
|
||||
description = "Reverse-engineering Domino's pizza sauce from official ingredient and nutrition data, tomato products, seasonings, oil, and water."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "dominos-pizza-sauce"
|
||||
title = "🍅 Domino's Pizza Sauce"
|
||||
@@ -258,4 +258,4 @@ I now have a complete formula:
|
||||
|dehydrated garlic|0.40|
|
||||
|**TOTAL**|**227**|
|
||||
|
||||
✅ Done.
|
||||
✅ Done.
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["dns"]
|
||||
date = 2025-02-16T00:00:00Z
|
||||
description = "Deploying an AWS Route 53 and Lambda dynamic DNS solution that updates records whenever a home public IP address changes."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "dynamic-dns"
|
||||
title = "🌐 Dynamic DNS"
|
||||
@@ -68,4 +68,4 @@ www.uuard.com can be updated with the following command:
|
||||
|
||||
The only thing left to be done is to move the provided `dyndns.sh` script to my server and set up a `cron` job. I chose to run my script each week “At 00:00 on Sunday” (`0 0 * * 0`).
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["cake", "earl-grey", "mixed-berries"]
|
||||
date = 2025-01-05T00:00:00Z
|
||||
description = "A photo gallery of Bailey's Earl Grey layer cake filled with mixed berries and decorated for sharing."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "earl-grey-cake"
|
||||
title = "👑 Earl Grey Cake with Mixed Berry Filling"
|
||||
@@ -12,4 +11,4 @@ author = "nicholas"
|
||||
Bailey made a cake, and I took some photos since her creations are always very beautiful. Nothing else going on here!
|
||||
|
||||
## Photos
|
||||
{{< gallery name="earl-grey-gallery" >}}
|
||||
{{< gallerynew name="earl-grey-gallery" >}}
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["proxy","infrastructure","traefik","crowdsec"]
|
||||
date = 2025-08-06T08:00:00-05:00
|
||||
description = "Centralizing external service access on an edge VM with Traefik reverse proxying and CrowdSec intrusion prevention."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "edge-reverse-proxy"
|
||||
title = "🧭 Centralized, Secure Reverse Proxy at the edge"
|
||||
@@ -159,4 +159,4 @@ After some time, I go check whether CrowdSec has banned any bots.
|
||||
```
|
||||
🎉 It works! The table generated by `cscli` shows a few IP addresses have been banned.
|
||||
|
||||
✅ Done.
|
||||
✅ Done.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["recipe", "egg-replacer", "food-science"]
|
||||
date = 2025-02-28T10:46:00-06:00
|
||||
description = "Formulating a lower-cost powdered egg substitute by comparing commercial products and balancing starch, fat, moisture, and leavening."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "egg-replacer"
|
||||
title = "🥚 Egg Replacer"
|
||||
@@ -95,4 +94,4 @@ Thus I arrive at my final formula, ready to test in a cake recipe.
|
||||
| Baking Powder | 1.50% | 0.4875 |
|
||||
| **TOTAL** | 100.00% | 50 |
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["automation"]
|
||||
date = 2024-06-18T02:47:45Z
|
||||
description = "Configuring Postfix to relay cron-job success and failure alerts through Amazon SES, with reusable shell email formatting."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "email-alerts"
|
||||
title = "📧 Email Alerts with Postfix & Amazon SES"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["travel"]
|
||||
tags = ["estes-park", "colorado", "photo-gallery"]
|
||||
date = 2023-01-02T12:00:00-06:00
|
||||
description = "Photos and video from a winter visit to Estes Park, Colorado."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "estes-park"
|
||||
title = "⛰️ Estes Park, CO"
|
||||
@@ -20,4 +19,4 @@ author = "nicholas"
|
||||
|
||||
## Photo Gallery
|
||||
|
||||
{{< gallery name="estes-park-gallery" >}}
|
||||
{{< gallerynew name="estes-park-gallery" >}}
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["mssql","c#"]
|
||||
date = 2024-11-04T05:22:54Z
|
||||
description = "Extracting and transforming USDA FoodData Central JSON into Microsoft SQL Server for a C# recipe and nutrition application."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "fooddata-central-ingest"
|
||||
title = "📑 Extracting & Transforming USDA's FoodData Central Data"
|
||||
@@ -204,4 +204,4 @@ using (var connection = new SqlConnection(connectionString))
|
||||
src="images/json_db_ingest.exe.png"
|
||||
caption="Ingest Console App" >}}
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["C#","MSSQL","blazor"]
|
||||
date = 2024-11-02T18:24:31Z
|
||||
description = "Adding ASP.NET Core Identity and a multi-user data model to a Blazor recipe application backed by SQL Server."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "formulation-web-app-I"
|
||||
title = "🔒 Adding User Account support to Web App"
|
||||
@@ -93,3 +93,4 @@ Table schema will be defined mostly by Microsoft Identity API.
|
||||
caption="The Whole Thang" >}}
|
||||
|
||||
Done.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["C#","MSSQL","blazor"]
|
||||
date = 2025-04-15T12:45:00-05:00
|
||||
description = "Building Formulation, a weight-based Blazor nutrition calculator for scaling recipes and calculating nutrient totals."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "formulation-web-app-II"
|
||||
title = "🥣 Formulation - Nutrition Calculator"
|
||||
@@ -107,4 +107,4 @@ The kitchen area is where one can, for example, find out how much protein is in
|
||||
|
||||
---
|
||||
|
||||
That is pretty much all. As with any software project, there are endless things still to be improved, corrected, or added, but I accomplished what I wanted with this project so I am allowing myself to call it "finished".
|
||||
That is pretty much all. As with any software project, there are endless things still to be improved, corrected, or added, but I accomplished what I wanted with this project so I am allowing myself to call it "finished".
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["automation","docker","git","devops","kubernetes"]
|
||||
date = 2025-05-08T12:50:00-05:00
|
||||
description = "Replacing Jenkins deployment jobs with a self-hosted Gitea Actions runner that builds containers and deploys them to Kubernetes."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "gitea-actions-kubernetes"
|
||||
title = "▶️ Gitea Actions & Kubernetes"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["build","plants"]
|
||||
tags = ["houseplants", "grow-lights", "lighting"]
|
||||
date = 2025-01-01T16:30:00Z
|
||||
description = "Building winter grow lights for houseplants from LED bulbs, EMT conduit, electrical fittings, and a later grow-panel upgrade."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "grow-lights"
|
||||
title = "💡 Houseplant Grow Lights"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["recipe", "dessert", "chocolate"]
|
||||
date = 2025-02-22T11:51:00-06:00
|
||||
description = "Converting a hot fudge recipe to weight measurements and testing economical, shelf-stable substitutions for dairy ingredients."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "hot-fudge"
|
||||
title = "🍦 Hot Fudge"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["build"]
|
||||
tags = ["electronics","hoverboard"]
|
||||
date = 2025-08-10T11:00:00-05:00
|
||||
description = "Diagnosing a failed hoverboard battery, salvaging reusable electronics, and installing a compatible replacement pack."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "hoverboard-battery-salvage-replacement"
|
||||
title = "🪫 Hoverboard Battery Salvage, Replacement"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["hypervisor","virtualization"]
|
||||
date = 2025-06-27T19:00:00-05:00
|
||||
description = "Separating storage from application services across dedicated machines and redesigning the home network's virtualized infrastructure."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "infrastructure-overhaul"
|
||||
title = "🔨 Infrastructure Overhaul"
|
||||
@@ -242,4 +242,4 @@ graph TD
|
||||
- Host VMs in Hyper-V hypervisor on Windows OS
|
||||
- Centralize persistent container storage in Kubernetes and Docker using NAS server NFS/SMB shares
|
||||
|
||||
✅ Done.
|
||||
✅ Done.
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["docker"]
|
||||
date = 2024-04-18T17:00:33Z
|
||||
description = "Capturing periodic frames from a Frigate IP-camera stream and assembling them into configurable time-lapses with FFmpeg."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "ipcam-timelapse"
|
||||
title = "📹 IP Camera Time-lapse in Frigate"
|
||||
@@ -201,4 +201,4 @@ ffmpeg -framerate 60 -pattern_type glob -i '*.png' -c:v libx265 -crf 28 -preset
|
||||
width="100%" >}}
|
||||
|
||||
|
||||
ReoLink RLC-810WA
|
||||
ReoLink RLC-810WA
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["automation","docker","jenkins","nginx"]
|
||||
date = 2025-04-06T10:18:00-06:00
|
||||
description = "Automating Hugo blog builds and versioned Nginx deployments with Git submodules, Docker, and a custom Jenkins agent."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "jenkins-deploy-web-log"
|
||||
title = "🚀 Hugo Site Deployment - Automating and Version Tracking"
|
||||
@@ -316,4 +316,4 @@ Jenkins successfully deployed the Hugo site.
|
||||
src="images/jenkins-build-hugo-success.jpg"
|
||||
caption="Successful hugo site deployment" >}}
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["automation","docker","jenkins","nginx"]
|
||||
date = 2025-03-30T18:00:00-06:00
|
||||
description = "Creating a Jenkins pipeline that builds and deploys a personal website into an Nginx container from a self-hosted Git repository."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "jenkins-deploy-website"
|
||||
title = "🤵🏻 Automating Personal Website Deployment"
|
||||
@@ -188,4 +188,4 @@ exit 0
|
||||
|
||||
When Jenkins receives the HTTP POST request at `https://build.uuard.com/job/personal-website-pipeline/build` with valid authentication it will initiate the build.
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["automation","docker","jenkins"]
|
||||
date = 2024-10-31T15:49:57Z
|
||||
description = "Running Jenkins in Docker and connecting it to Gitea as the foundation for automated development build and deployment pipelines."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "jenkins"
|
||||
title = "🐋 Configuring Development Pipeline with Jenkins & Docker"
|
||||
@@ -195,4 +195,4 @@ This starts the container. The video below shows the process of starting the age
|
||||
width="100%"
|
||||
>}}
|
||||
|
||||
Done! This will give me a foundation on which to build out a development pipeline for any number of my software projects.
|
||||
Done! This will give me a foundation on which to build out a development pipeline for any number of my software projects.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["travel"]
|
||||
tags = ["key-west", "florida", "honeymoon"]
|
||||
date = 2025-01-13T20:27:44Z
|
||||
description = "A brief photo journal from a January 2025 honeymoon in Key West, Florida."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "key-west"
|
||||
title = "🏝️ Key West, FL"
|
||||
@@ -11,4 +10,4 @@ author = "nicholas"
|
||||
|
||||
We had our honeymoon in Key West 2025-01-13 - 2025-01-17. I did not take many photos.
|
||||
|
||||
{{< gallery name="key-west-gallery" >}}
|
||||
{{< gallerynew name="key-west-gallery" >}}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["llm","docker"]
|
||||
date = 2025-03-12T10:54:00-06:00
|
||||
description = "Deploying a private local language-model stack with an NVIDIA GPU, Ollama, Open WebUI, and Docker Compose."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "local-llm-basic-setup"
|
||||
title = "🦙 Self-hosting LLM with Ollama & OpenWebUI"
|
||||
@@ -111,4 +111,4 @@ This screenshot shows that the model is indeed using my GPU to do all manner of
|
||||
>}}
|
||||
|
||||
## Potential Uses
|
||||
I can integrate locally-hosted LLM APIs with new software projects. Who knows.
|
||||
I can integrate locally-hosted LLM APIs with new software projects. Who knows.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["recipe", "food-science", "gyro", "cheesesteak"]
|
||||
date = 2024-07-13T18:16:10Z
|
||||
description = "Experimenting with emulsified beef-and-lamb mixtures as a repeatable base for gyro meat and cheesesteaks."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "meat-emulsions"
|
||||
title = "🥩 Meat Emulsions - Gyros & Cheesesteaks"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["muffins", "mixed-berries", "egg-replacer"]
|
||||
date = 2025-02-22T13:40:00-06:00
|
||||
description = "Making mixed-berry muffins with a quick baking-powder, water, and oil substitute for one of the required eggs."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "mixed-berry-muffins"
|
||||
title = "🫐 Mixed Berry Muffins"
|
||||
@@ -15,4 +14,5 @@ Bailey made muffins. We only have one egg left. The recipe calls for two eggs, o
|
||||
src="videos/timelapse-bake-mixed-berry-muffins.webm"
|
||||
width="100%" >}}
|
||||
|
||||
{{< gallery name="mixed-berry-muffin-gallery">}}
|
||||
{{< gallerynew name="mixed-berry-muffin-gallery">}}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["food"]
|
||||
tags = ["reverse-engineer","MOD"]
|
||||
date = 2024-07-19T15:27:08Z
|
||||
description = "Reverse-engineering MOD Pizza's original thin crust through nutrition data, ingredient analysis, spreadsheet calculations, and baking trials."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "mod-pizza-crust"
|
||||
title = "🍕 MOD Pizza Crust"
|
||||
@@ -110,4 +110,4 @@ I have succeeded in producing a crispy, light, thin and strong crust.
|
||||
## Variation
|
||||
To make an even crispier crust, I dried out the bottom of the crust. The dough is rolled out, inverted, dried overnight, flipped, topped, and finally baked.
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["mssql"]
|
||||
date = 2024-10-01T14:20:28Z
|
||||
description = "Moving a Blazor recipe application from an in-memory store to containerized SQL Server with Entity Framework, users, and protected connection strings."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "mssql-db-config"
|
||||
title = "💽 Configuring Microsoft SQL Server Database"
|
||||
@@ -173,4 +173,4 @@ Finally I can reference this connection string in Program.cs to connect to the S
|
||||
builder.Services.AddDbContext(opt => opt.UseSqlServer(builder.Configuration.GetConnectionString("Formulation")));
|
||||
```
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["build"]
|
||||
tags = ["nas", "server", "zfs"]
|
||||
date = 2021-09-09T14:53:39Z
|
||||
description = "Planning and assembling a network-attached storage server with shucked drives, disk testing, and a ZFS filesystem."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "server-build"
|
||||
title = "💾 Server Build"
|
||||
@@ -155,4 +154,4 @@ sudo service smbd restart
|
||||
|
||||
I should be able to access this share using the credentials of valid user which was defined in `/etc/samba/smb.conf`.
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["build"]
|
||||
tags = ["nas", "server", "rackmount"]
|
||||
date = 2024-11-15T17:43:02Z
|
||||
description = "Refreshing a NAS with more memory, a new operating-system disk, and a move into a Rosewill rack-mount chassis."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "server-build-refresh"
|
||||
title = "💾 Server Build Refresh"
|
||||
@@ -24,4 +23,4 @@ My NAS server only has 16 GB RAM. I upgrade this to 32 GB. upgrade the OS disk a
|
||||
## Upgrading
|
||||
In a previous log entry I documented the build process of my storage server. At the time its only intended purpose was managing my file storage and hosting a media streaming service. Its purpose has expanded to a degree that it begs for additional RAM. I will double the RAM from 16GB to 32 GB, reaching the limitations of the motherboard. I will replace the WD Blue SA510 SSD storage drive, (which will replace a very slow mechanical drive in a laptop), with a Samsung 970 EVO Plus. Old and new components will be installed in a new chassis Rosewill RSV-R4000U and rack-mounted alongside the other computer.
|
||||
|
||||
{{< gallery name="nas-build-gallery" >}}
|
||||
{{< gallerynew name="nas-build-gallery" >}}
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["travel"]
|
||||
tags = ["pacific-northwest", "photo-gallery", "road-trip"]
|
||||
date = 2023-05-31T12:00:00-05:00
|
||||
description = "A photo gallery from a May 2023 trip through the Pacific Northwest."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "pacific-northwest"
|
||||
title = "🌲 Pacific Northwest"
|
||||
@@ -23,4 +22,5 @@ Bailey and I went to PNW coast for a few days.
|
||||
|
||||
|
||||
## Photo Gallery
|
||||
{{< gallery name="pnw-gallery" >}}
|
||||
{{< gallerynew name="pnw-gallery" >}}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["food"]
|
||||
tags = ["reverse-engineer","papa johns"]
|
||||
date = 2024-07-15T22:56:46Z
|
||||
description = "Reverse-engineering Papa John's garlic dipping sauce from its nutrition facts, ingredient statement, shortening, garlic, and soy lecithin."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "papa-johns-garlic-butter"
|
||||
title = "🧈 Papa John's Garlic Butter Dipping Sauce"
|
||||
@@ -91,4 +91,4 @@ As part of my lazy process, I chose to add garlic powder first because it can al
|
||||
### Soy Lecithin
|
||||
Lecithin is an emulsifier – it will bind the water to the fat and create a smooth creamy sauce. To continue the process, I add soy lecithin in increments of ~0.1% by weight until the blended mixture resembled the garlic sauce at the same temperature, which resulted in eventually adding a total of 0.5% by weight of the total weight of the mixture. The formulation is complete:
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["build"]
|
||||
tags = ["pc", "computer", "hardware"]
|
||||
date = 2024-11-15T17:43:08Z
|
||||
description = "Selecting components and assembling a desktop PC for faster software development, photo editing, and video work."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "pc-build"
|
||||
title = "🖥️ PC Build"
|
||||
@@ -57,4 +56,4 @@ The process of assembly was straightforward. I simply followed the instructions
|
||||
{{< image
|
||||
src="images/pc-assembled-drawer.jpg"
|
||||
caption="Computer in a drawer" >}}
|
||||
Done.
|
||||
Done.
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["photogrammetry"]
|
||||
date = 2025-07-02T20:00:00-05:00
|
||||
description = "Digitally preserving a Tongan wood carving by photographing it from many angles and processing the images into a 3D model."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "photogrammetry"
|
||||
title = "🗿 Photogrammetry - Tongan Wood Carving"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["cookie"]
|
||||
date = 2025-03-21T10:28:00-06:00
|
||||
description = "A simple recipe for rich pistachio shortbread cookies made by Bailey."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "pistachio-shortbread-cookie"
|
||||
title = "🍪 Pistachio Shortbread Cookie"
|
||||
@@ -23,4 +23,4 @@ This is stupid easy recipe Bailey made.
|
||||
|
||||
Note: The units for all weights are in grams.
|
||||
|
||||
{{< gallery name="pistachio-shortbread-cookie-gallery" >}}
|
||||
{{< gallerynew name="pistachio-shortbread-cookie-gallery" >}}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["recipe", "bread", "pita"]
|
||||
date = 2024-04-30T17:17:58Z
|
||||
description = "Making sturdy, soft pocket bread for stuffing, frying as chalupa shells, and freezing for later meals."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "pita"
|
||||
title = "🥙 Pita"
|
||||
@@ -15,7 +14,7 @@ This is not traditional or authentic pita I made this bread to be strong, pliabl
|
||||
src="videos/pita-baking-timelapse.webm"
|
||||
width="100%" >}}
|
||||
|
||||
{{< gallery name="pita-gallery" >}}
|
||||
{{< gallerynew name="pita-gallery" >}}
|
||||
|
||||
## Process
|
||||
### 1. Preheat Steel Plate
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["food"]
|
||||
tags = ["reverse-engineer","pretzel"]
|
||||
date = 2024-12-26T12:00:00Z
|
||||
description = "Reverse-engineering Auntie Anne's original pretzel nuggets with nutrition calculations, a formulated dough, and a lye bath."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "auntie-annes-pretzel-nuggets"
|
||||
title = "🥨 Auntie Anne's Pretzel Nuggets"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["virtualization"]
|
||||
date = 2024-11-10T15:55:42Z
|
||||
description = "Migrating a physical Debian server into Proxmox so it can run alongside a virtualized Windows Server domain controller."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "proxmox-migration"
|
||||
title = "🤖 Server Virtualization Migration"
|
||||
@@ -75,4 +75,4 @@ Now I can use clonezilla to restore my OS to a virtual disk.
|
||||
### Expand OS Root Partition
|
||||
I shrank it to 40 GB, now i need to fill up the virtual disk. I attach the gparted disk to the VM and boot to it. This time I expand the partition to occupy the remaining used portion of the virtual disk.
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["food"]
|
||||
tags = ["reverse-engineer","chipotle"]
|
||||
date = 2024-12-06T15:36:00Z
|
||||
description = "Formulating a thin, freezer-friendly imitation of Chipotle queso blanco using nutrition data, cheese, sodium citrate, dairy, and seasonings."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "queso-blanco"
|
||||
title = "🧀 Chipotle Queso Blanco"
|
||||
@@ -74,3 +74,4 @@ The formulation will come down to several factors that I care about.
|
||||
1. Consistency -- This will be about how much water is included. Chipotle has 480 calories for every 8 oz. portion of Queso Blanco. Basically if I begin with a weight of 8 oz and subtract the weight of cheese that amounts to 480 calories, and add the remaining weight in water, I should have a similar consistency after thickening.
|
||||
|
||||
The game I need to play here is to fiddle with the proportions of cheese, sour cream, milk, and water until I achieve a macronutrient makeup similar to Chipotle's. In other words, the constraints are basically the nutrition table above. There is a small enough range of possible values of these proportions that I can get pretty close without resorting to linear algebra. The mixture must thin and therefore contain a lot of water.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["react","typescript","javascript"]
|
||||
date = 2025-04-12T00:00:00-06:00
|
||||
description = "Reviving an old React storage application by replacing React Scripts with Vite, updating packages, and migrating JavaScript to TypeScript."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "react-app-resurrection"
|
||||
title = "⚛️ React App Resurrection"
|
||||
@@ -236,4 +236,4 @@ It works. The app source is hosted on my [git repository](https://git.uuard.com/
|
||||
|
||||
{{< video
|
||||
src="videos/demo-react-app.mp4"
|
||||
width="100%" >}}
|
||||
width="100%" >}}
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["food"]
|
||||
tags = ["recipe","reverse-engineer","taco bell"]
|
||||
date = 2025-04-14T20:40:00-05:00
|
||||
description = "Formulating seasoned refried beans inspired by Taco Bell, with a repeatable process and direct comparison to the original product."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "refried-beans"
|
||||
title = "🫘 Taco Bell Seasoned Refried Beans"
|
||||
@@ -136,4 +136,4 @@ Versus Taco Bell's *Seasoned Refried Beans*:
|
||||
- $\approx 1.18$% salt
|
||||
- $\approx 4$% fat.
|
||||
|
||||
Pretty close.
|
||||
Pretty close.
|
||||
@@ -1,207 +0,0 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["router","opnsense"]
|
||||
date = 2025-08-27T18:30:00-05:00
|
||||
description = "Replacing a consumer router with virtualized OPNsense, a managed switch, VLANs, and a router-on-a-stick network design."
|
||||
draft = false
|
||||
slug = "router-build"
|
||||
title = "🔀 Router Build"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
My router (**Netgear R6400v2**) lacks many of the features I would like to use and become familiar with:
|
||||
- VLANs
|
||||
- Advanced routing, firewall
|
||||
- Monitoring, logging, alerts
|
||||
- etc.
|
||||
|
||||
**I need a new router.**
|
||||
|
||||
I have a few options from here:
|
||||
|
||||
1. ❌ **Write custom firmware to existing router** (*DD-WRT, OpenWRT, Tomato, etc.*)
|
||||
- ➕ Fun
|
||||
- ➕ Free
|
||||
- ➖ Too much jank
|
||||
|
||||
2. ❌ **Buy new hardware**
|
||||
- ➕ Very easy setup
|
||||
- ➖ Boring
|
||||
- 💲 Could require expensive/high-end router hardware
|
||||
|
||||
3. ✅ **Repurpose existing hardware**
|
||||
- ➕ Fun
|
||||
- ➕ Makes use of an unused **Dell OptiPlex 5050 Micro**
|
||||
- ➕ Can run proper router OS **OPNsense**
|
||||
- ➕ Repurpose router as wireless access point
|
||||
|
||||
---
|
||||
|
||||
## 🪵 Router-on-a-stick (ROAS)
|
||||
My VM host (where I will build my router) has only a single NIC, with only one physical port. That means I cannot replicate the exact behavior of ordinary consumer routers, which are typically configured with a dedicated WAN port, and several dedicated LAN ports. The router simply routes between the two. Very simple. I need to find a way to replicate the *function* of such a router without the same *hardware*.
|
||||
|
||||
I have a couple of options:
|
||||
|
||||
1. ❌ Configure the **virtual switch** on my VM host to perform 802.1Q VLAN tagging for router VM.
|
||||
- ➕ Free, no additional hardware required
|
||||
|
||||
2. ✅ Configure a **managed physical switch** to handle 802.1Q VLAN tagging and trunking to the router
|
||||
- ➕ Gain additional physical ports
|
||||
- 💲 Addtional hardware required
|
||||
|
||||
I want the extra ports! I got a **Netgear GS108E-400NAS**. For no other reason than it was available at my local *Best Buy*.
|
||||
|
||||
---
|
||||
|
||||
## 🖧 Logical Network Topology
|
||||
This diagram describes approxmiately what I my network will look like:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
isp["🌐ISP"]
|
||||
modem["📡Modem"]
|
||||
switch["🔀Switch"]
|
||||
subgraph vm-host["VM Host"]
|
||||
router["🛡️Router (OPNsense)"]
|
||||
end
|
||||
wap["🛜WAP"]
|
||||
nas["🗄️NAS"]
|
||||
workstation["🖥️Workstation"]
|
||||
subgraph wireless["Wireless Devices"]
|
||||
laptop["💻Laptop"]
|
||||
phone["📱Phone"]
|
||||
printer["🖨️Printer"]
|
||||
end
|
||||
|
||||
isp --- modem
|
||||
modem --- switch
|
||||
switch --- |"🪵 Trunk (VLANs 10,100)"| router
|
||||
switch --- wap
|
||||
switch --- nas
|
||||
switch --- workstation
|
||||
wap -.- wireless
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔀 Switch Setup & Port VLAN Assignment Table
|
||||
- First, I need to choose a subnet for my network and assign my switch an IP accordingly. I chose 10.0.0.0/8 subnet. It just looks good.
|
||||
|
||||
- Next I cofigure my switch with a **trunk port**. This port will link to the router and carry multiple VLANs: **VLAN 10** (LAN), **VLAN 100** (WAN). This allows my router to distinguish between WAN and LAN with just one physical port. Instead of a phyiscal port to ditinguish WAN and LAN, the distinction is made virtually using VLAN tags.
|
||||
|
||||
- This means I will also need to configure a separate port for VLAN 100 (modem/ISP uplink), so that all traffic on that port is tagged and forwarded as WAN traffic.
|
||||
|
||||
| Port | Device | VLAN Mode | PVID | Tagged VLANs |
|
||||
|-|-|-|-|-|
|
||||
| 1 | Router | Trunk | 10 | 10,100 |
|
||||
| 2 | Workstation | Access | 10 | - |
|
||||
| 3 | NAS | Access | 10 | - |
|
||||
| 4 | WAP | Access | 10 | - |
|
||||
| 5 | - | Access | 10 | - |
|
||||
| 6 | - | Access | 10 | - |
|
||||
| 7 | - | Management | 1 | - |
|
||||
| 8 | Modem | Access | 100 | - |
|
||||
|
||||
---
|
||||
|
||||
**Switch VLAN tagging**
|
||||
```mermaid
|
||||
flowchart TD
|
||||
lan["🏠LAN"]
|
||||
modem["🌐Modem/ISP"]
|
||||
switch[🔀Switch]
|
||||
subgraph router["🛡️Router"]
|
||||
wan_interface["🌐WAN Interface"]
|
||||
lan_interface["🏠LAN Interface"]
|
||||
end
|
||||
|
||||
lan -- "`
|
||||
*🗅untagged*
|
||||
🔵access port **VLAN 10**
|
||||
`" --> switch
|
||||
modem -- "`
|
||||
*🗅untagged*
|
||||
🔵access port **VLAN 100**`" --> switch
|
||||
|
||||
switch -- "*🏷️tagged* VLAN 10" --> lan_interface
|
||||
switch -- "*🏷️tagged* VLAN 100" --> wan_interface
|
||||
```
|
||||
✅ Done. Now I configure a VM for the router.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ VM Configuration
|
||||
I need to configure a VM to serve as a router:
|
||||
- COnfigure VM specs:
|
||||
- 20 GB vdisk
|
||||
- 3 GB RAM
|
||||
- 1 vCPU
|
||||
- Install OPNsense OS
|
||||
- Configure VM network adapter with static MAC address (spoof old netgear router MAC, keep current DHCP IP)
|
||||
- Configure host OS network adapter to carry VLAN ID 10 only
|
||||
- Configure VM network adapter to carry VLANs ID 10,100
|
||||
|
||||
✅ Done. Now I can configure OPNsense.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ OPNsense
|
||||
|
||||
{{< image
|
||||
src="images/OPNsense-dashboard.png"
|
||||
caption="OPNsense Dashboard" >}}
|
||||
|
||||
##### Interfaces
|
||||
According to the plan, I need to configure OPNsense with an interface for each VLAN I configured:
|
||||
- VLAN **10** for **LAN**
|
||||
- VLAN **100** for **WAN**
|
||||
|
||||
##### DNSMasq DHCP
|
||||
Since I want most of my devices to be assigned IPs dynamically, I enable DHCP on the LAN interface.
|
||||
- Start address: 10.0.10.3
|
||||
- End address: 10.0.10.100
|
||||
- 10.0.10.100 - 10.0.10.200 are reserved for static / wired devices.
|
||||
|
||||
##### Unbound DNS
|
||||
I also want to use my router as a DNS server for local DNS resolution, so I enable **Unbound DNS**. This is where I can configure A records (host override), and CNAME records (alias).
|
||||
|
||||
**For example:**
|
||||
|
||||
| Record Type | Name | Value |
|
||||
|-|-|-|
|
||||
| A | proxy.internal.domain.com | 10.0.10.100 |
|
||||
| CNAME | git.domain.com | proxy.internal.domain.com |
|
||||
|
||||
##### Query Forwarding
|
||||
Now that I have given my wired devices static IPs, I can do query forwarding to Pi-Hole. This will enable network-wide ad blocking.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
client[💻Client] --> router-dns["`
|
||||
🛡️Router
|
||||
(Local DNS)
|
||||
`"]
|
||||
router-dns --> pihole[🕳️Pi-hole]
|
||||
pihole --> upstream["`
|
||||
☁️ Upstream DNS
|
||||
(e.g. 8.8.8.8)`"
|
||||
]
|
||||
```
|
||||
##### Firewall - GeoIP
|
||||
I use [MaxMind GeoIP database](https://AccountID:LicenseKey@download.maxmind.com/geoip/databases/GeoLite2-Country-CSV/download?suffix=zip) to create a firewall alias. Then I configured a firewall rule on WAN interface which blocks any IP that matches those configured in the alias. I am blocking IPs from most countries.
|
||||
|
||||
This nearly eliminates unwanted traffic from annoying and/or malicious bots scanning and scraping my network
|
||||
|
||||
**MaxMind GeoIP database**:`https://AccountID:LicenseKey@download.maxmind.com/geoip/databases/GeoLite2-Country-CSV/download?suffix=zip`
|
||||
|
||||
|
||||
🎉 It works!
|
||||
|
||||
{{< video
|
||||
src="videos/router-traffic-graph-live.mp4"
|
||||
width="100%"
|
||||
>}}
|
||||
|
||||
|
||||
✅ Done.
|
||||
@@ -1,8 +1,8 @@
|
||||
+++
|
||||
categories = ["build"]
|
||||
tags = ["organization", "labels", "spices"]
|
||||
tags = []
|
||||
date = 2025-07-23T17:00:00-05:00
|
||||
description = "Designing and applying durable, readable labels to stackable deli containers for a more organized seasoning collection."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "seasoning-labels"
|
||||
title = "🧂 Seasoning Labels"
|
||||
@@ -132,4 +132,4 @@ Basically:
|
||||
|
||||
Rather than copying, pasting, and printing each individual label one at a time, which would take an eternity and waste a lot of tape with on account of the generous margins enforced by the printer, I add three whitespace characters between each seasoning label, and print one long string with as many characters as the printer will allow at once. This is another niggling limitation of the label printer: I can only print ~25 inches of tape at a time, so I will have to print the bulk labels in a few batches. Later, I will manually trim the mega-labels into individual labels with scissors (The label printer includes its own *manual* label slicer, so this is no less work).
|
||||
|
||||
This all worked well enough for my purposes, and I have no need of a more sophisticated label printer.
|
||||
This all worked well enough for my purposes, and I have no need of a more sophisticated label printer.
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software"]
|
||||
tags = ["automation","grafana","influxdb","telegraf"]
|
||||
date = 2025-05-16T08:00:00-05:00
|
||||
description = "Building a web-based server monitoring dashboard with Telegraf metrics, InfluxDB storage, and Grafana visualizations."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "server-monitor"
|
||||
title = "📈 Server Monitoring with Grafana"
|
||||
@@ -278,4 +278,4 @@ Here is what this looks like alongside the rest of the metrics I configured:
|
||||
|
||||
Each of the visualizations in this image are created with Flux queries similar to the example above. I don't bother writing each one out here. Instead, I attach a [JSON file](files/master.json) that can be imported into Grafana. It contains among many other details all of the queries used in the image.
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["build"]
|
||||
tags = ["server", "rackmount", "homelab"]
|
||||
date = 2024-11-15T17:43:15Z
|
||||
description = "Moving home-server hardware into a spacious rack system for better cooling, expansion, organization, and maintenance access."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "server-rack"
|
||||
title = "🗄️ Server Rack"
|
||||
@@ -44,9 +43,10 @@ I compared a lot of server racks, and most were too expensive to consider. The r
|
||||
## Photos
|
||||
I have some photos of the rack and its components. It is tucked under the desk in the corner of the apartment living room. Very pleased. Improvements are in order for the external cable management once everything is "stable".
|
||||
|
||||
{{< gallery name="server-rack-gallery" >}}
|
||||
{{< gallerynew name="server-rack-gallery" >}}
|
||||
|
||||
## "Servers"
|
||||
I have two chassis mounted in the rack. I have a couple of posts relating to these machines:
|
||||
- server
|
||||
- pc
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["build"]
|
||||
tags = ["solar", "battery", "truck", "camping"]
|
||||
date = 2021-09-09T19:06:11Z
|
||||
description = "Building a truck-mounted wooden deck and 1,200-watt solar generator for camping, tailgating, refrigeration, lights, and cooking."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "solar-generator"
|
||||
title = "☀️ Mobile Deck & Solar Generator"
|
||||
@@ -143,4 +142,4 @@ The simplified process of assembly can be expressed in these steps:
|
||||
|
||||
{{< image
|
||||
src="images/truck-deck-night-distant.jpg"
|
||||
caption="Deck at night from a distance" >}}
|
||||
caption="Deck at night from a distance" >}}
|
||||
@@ -1,8 +1,7 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["bread", "sourdough", "baking"]
|
||||
date = 2025-02-22T12:09:00-06:00
|
||||
description = "Pursuing sourdough flavor and technique without maintaining a traditional starter, with notes on tools, proofing, and storage."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "sourdough"
|
||||
title = "🥖 'Sourdough'"
|
||||
@@ -44,3 +43,4 @@ The reasons I use rice flour:
|
||||
- Contains no gluten - no stickiness
|
||||
- low protein content -- does not char easily.
|
||||
- fine texture
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
+++
|
||||
+++
|
||||
categories = ["build"]
|
||||
tags = ["diy", "whiteboard", "woodworking"]
|
||||
tags = [""]
|
||||
date = 2025-05-02T12:00:00-06:00
|
||||
description = "Building a sturdy, inexpensive medium-sized whiteboard from readily available materials instead of buying a small commercial board."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "whiteboard"
|
||||
title = "🧼 Whiteboard Build"
|
||||
@@ -69,6 +69,6 @@ I will have a *Home Depot* employee to cut the board for me in the store. It wil
|
||||
|
||||
## Images
|
||||
|
||||
{{< gallery name="whiteboard-gallery" >}}
|
||||
{{< gallerynew name="whiteboard-gallery" >}}
|
||||
|
||||
Done.
|
||||
Done.
|
||||
@@ -2,7 +2,7 @@
|
||||
categories = ["software","build"]
|
||||
tags = ["docker"]
|
||||
date = 2024-08-21T18:13:24Z
|
||||
description = "Turning a Nikon Z50 into a high-quality wildlife camera with Frigate, Docker, object detection, recording, and live streaming."
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "wildlife-stream"
|
||||
title = "📷 Wildlife Cam Livestream with Frigate + Nikon Z50"
|
||||
|
||||
@@ -6,7 +6,7 @@ timeout = 60000
|
||||
# website title
|
||||
title = "log - Nicholas Ward"
|
||||
# language code ["en", "zh-CN", "fr", "pl", ...]
|
||||
locale = "en"
|
||||
languageCode = "en"
|
||||
# language name ["English", "简体中文", "Français", "Polski", ...]
|
||||
languageName = "English"
|
||||
# whether to include Chinese/Japanese/Korean
|
||||
@@ -116,7 +116,7 @@ ignoreErrors = ["error-remote-getjson", "error-missing-instagram-accesstoken"]
|
||||
# whether to show the author
|
||||
author = true
|
||||
# Site creation time
|
||||
since = 2021
|
||||
since = 2024
|
||||
# ICP info only in China (HTML format is supported)
|
||||
icp = ""
|
||||
# license info (HTML format is supported)
|
||||
@@ -476,7 +476,6 @@ ignoreErrors = ["error-remote-getjson", "error-missing-instagram-accesstoken"]
|
||||
# located in "assets/"
|
||||
# Or
|
||||
# someJavascript = "https://cdn.example.com/some.js"
|
||||
analytics = '''<script defer src="https://analytics.uuard.com/script.js" data-website-id="12c78397-e26b-4311-ba0a-3ce72c40716a"></script>'''
|
||||
# Page SEO config
|
||||
[params.page.seo]
|
||||
# image URL
|
||||
|
||||
+2
-9
@@ -3,22 +3,15 @@
|
||||
{{ $file := printf "%s.yaml" $galleryName }}
|
||||
{{ $galleryData := .Page.Resources.GetMatch $file | transform.Unmarshal }}
|
||||
|
||||
<div class="gallery gallery-new">
|
||||
<div class="gallery grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{{ range $galleryData.images }}
|
||||
{{ $imageURL := printf "%s/%s/%s" site.Params.filesBase $slug .src }}
|
||||
|
||||
<figure class="{{ .class | default "" }}">
|
||||
<a
|
||||
class="lightgallery"
|
||||
href="{{ $imageURL }}"
|
||||
data-thumbnail="{{ $imageURL }}"
|
||||
{{ with .caption }}data-sub-html="{{ . }}"{{ end }}>
|
||||
<img
|
||||
src="{{ $imageURL }}"
|
||||
src="{{ site.Params.filesBase }}/{{ $slug }}/{{ .src }}"
|
||||
alt="{{ .alt | default "" }}"
|
||||
loading="lazy"
|
||||
decoding="async" />
|
||||
</a>
|
||||
{{ with .caption }}
|
||||
<figcaption>{{ . }}</figcaption>
|
||||
{{ end }}
|
||||
@@ -1,24 +1,16 @@
|
||||
{{ $src := .Get "src" }}
|
||||
{{ $alt := .Get "alt" }}
|
||||
{{ $slug := .Page.Slug }}
|
||||
{{ $imageURL := printf "%s/%s/%s" site.Params.filesBase $slug $src }}
|
||||
{{ $caption := .Get "caption" }}
|
||||
|
||||
<figure>
|
||||
<a
|
||||
class="lightgallery"
|
||||
href="{{ $imageURL }}"
|
||||
data-thumbnail="{{ $imageURL }}"
|
||||
{{ with $caption }}data-sub-html="{{ . }}"{{ end }}>
|
||||
<img
|
||||
src="{{ $imageURL }}"
|
||||
alt="{{ $alt }}"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width="{{ .Get "width" | default "800" }}" />
|
||||
</a>
|
||||
<img
|
||||
src="{{ site.Params.filesBase }}/{{ $slug }}/{{ $src }}"
|
||||
alt="{{ $alt }}"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width="{{ .Get "width" | default "800" }}" />
|
||||
|
||||
{{ with $caption }}
|
||||
{{ with .Get "caption" }}
|
||||
<figcaption>{{ . }}</figcaption>
|
||||
{{ end }}
|
||||
</figure>
|
||||
</figure>
|
||||
Submodule container/site/themes/LoveIt updated: ea76e2b8b1...4d614e6f78
@@ -1,13 +0,0 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: nicholas
|
||||
resources:
|
||||
- web-log-deployment.yaml
|
||||
- web-log-service.yaml
|
||||
- web-log-ingress.yaml
|
||||
- web-log-files-deployment.yaml
|
||||
- web-log-files-service.yaml
|
||||
- web-log-files-ingress.yaml
|
||||
images:
|
||||
- name: git.uuard.com/nicholas/web-log
|
||||
newTag: 45b11ce88ad5a0e6854f9ba40710f56d66e6b955
|
||||
@@ -1,60 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web-log
|
||||
labels:
|
||||
app.kubernetes.io/name: web-log
|
||||
spec:
|
||||
replicas: 2
|
||||
revisionHistoryLimit: 5
|
||||
progressDeadlineSeconds: 180
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 0
|
||||
maxSurge: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: web-log
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: web-log
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
containers:
|
||||
- name: web-log
|
||||
image: git.uuard.com/nicholas/web-log
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
@@ -1,65 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web-log-files
|
||||
labels:
|
||||
app.kubernetes.io/name: web-log-files
|
||||
spec:
|
||||
replicas: 2
|
||||
revisionHistoryLimit: 5
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 0
|
||||
maxSurge: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: web-log-files
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: web-log-files
|
||||
spec:
|
||||
containers:
|
||||
- name: web-log-files
|
||||
image: nginx:1.30.3-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: http
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumeMounts:
|
||||
- name: web-log-files
|
||||
mountPath: /usr/share/nginx/html
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: web-log-files
|
||||
nfs:
|
||||
server: nas.hosts.uuard.com
|
||||
path: /mnt/data-pool/web-log-files
|
||||
readOnly: true
|
||||
@@ -1,19 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: web-log-files
|
||||
labels:
|
||||
app.kubernetes.io/name: web-log-files
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: files.web-log.uuard.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: web-log-files
|
||||
port:
|
||||
name: http
|
||||
@@ -1,15 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: web-log-files
|
||||
labels:
|
||||
app.kubernetes.io/name: web-log-files
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: web-log-files
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
@@ -1,19 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: web-log
|
||||
labels:
|
||||
app.kubernetes.io/name: web-log
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: web-log.uuard.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: web-log
|
||||
port:
|
||||
name: http
|
||||
@@ -1,15 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: web-log
|
||||
labels:
|
||||
app.kubernetes.io/name: web-log
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: web-log
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
Reference in New Issue
Block a user