Author SHA1 Message Date
nicholas 0145b99cdf initial post 2026-07-08 14:36:20 -05:00
nicholas 03ececa157 🏗️ Home Ops Upgrades (#33)
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 44s
2026-07-08 11:45:40 -05:00
nicholas 120cb71cf8 change site creation time to 2021 (first year with a blog post)
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 28s
2026-07-08 10:26:29 -05:00
nicholas 84cf56b693 🔝 Server Top
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 19s
2026-07-07 08:19:11 -05:00
nicholas 69fc4281f0 🪴 Plant Repotting
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 1m21s
2026-07-06 22:17:36 -05:00
nicholas 34af072f50 update hugo version
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 17s
2026-05-15 14:34:36 -07:00
nicholas 97bd5615d4 🌡️ Replacing Thermostat Housing Assembly
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 18s
2026-05-15 14:06:29 -05:00
nicholas c8c260dcbc ☎️ VoIP
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 16s
2026-05-15 13:52:18 -05:00
nicholas 87faf40194 🎄 Christmas 2025
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 17s
2025-12-26 12:27:50 -06:00
nicholas 581f0d45da 🔌 Cable Managing
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 53s
2025-12-26 10:10:43 -06:00
nicholas 28980cb3f9 🫓 Par-baking Pizza Crusts
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 48s
2025-12-14 17:28:45 +00:00
nicholas c777230dc4 🛒 Plant Cart Upgrade
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 28s
2025-12-11 23:34:12 +00:00
nicholas acf6c828a5 finish post
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 1m0s
2025-12-07 09:54:21 -06:00
nicholas 0b2a701134 post 2025-10-28 16:04:45 -05:00
nicholas 0f2a3bb547 finish post (closes #14)
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 2m16s
2025-08-27 23:45:33 +00:00
nicholas 5a3c9edf65 add intro content, outline (refs #14) 2025-08-27 23:45:33 +00:00
nicholas 03fd78debd add post, front matter 2025-08-27 23:45:33 +00:00
nicholas a07aa04389 finish post (closes #12)
Build & Push Hugo Site Image / Build & Push Image (push) Successful in 12s
2025-08-12 12:16:06 -05:00
17 changed files with 1003 additions and 10 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
- name: ⚙️ Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: '0.148.1'
hugo-version: '0.161.1'
extended: true
- name: 🏗️ Build Hugo site
@@ -0,0 +1,35 @@
+++
categories = ["build","plants"]
tags = ["plants"]
date = 2025-08-08T08:00:00-05:00
description = ""
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.
@@ -0,0 +1,36 @@
+++
categories = ["food"]
date = 2025-12-07T09:00:00Z
description = ""
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
{{< gallerynew name="par-bake-pizza-dough-gallery" >}}
@@ -0,0 +1,16 @@
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"
@@ -0,0 +1,32 @@
+++
categories = ["build"]
date = 2025-12-10T20:03:00-06:00
description = ""
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
{{< gallerynew name="server-rack-gallery" >}}
@@ -0,0 +1,28 @@
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: ""
@@ -0,0 +1,63 @@
+++
categories = ["christmas"]
date = 2025-10-28T16:00:00Z
description = ""
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" >}}
@@ -0,0 +1,67 @@
+++
categories = ["vehicle"]
date = 2026-01-06T16:00:00Z
description = ""
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.
@@ -0,0 +1,33 @@
+++
categories = ["build"]
date = 2026-01-22T20:00:00Z
description = ""
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.
@@ -0,0 +1,167 @@
+++
categories = ["software"]
tags = ["freepbx","vm","bulkvs","sip","voip"]
date = 2026-03-01T08:00:00Z
description = ""
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.
@@ -0,0 +1,21 @@
+++
categories = ["bicycle","food"]
date = 2026-05-10T16:00:00Z
description = ""
draft = false
slug = "2026-05-10-summer-bicycle-eats"
title = "🧺 \"On a bike, you can go anywhere\""
author = "nicholas"
+++
Basically: bicycling, eating food, nice weather. Good things. Combine = good.
I can enjoy my food in comfort of own home, yes, ok. But time passes too quickly like this and sometimes a new place fixes this, and other latent problems. And why not bring some food to this new place? And why not a new food? Uncious salamis, a pickle or two, a new flavor of carbonated beverage, hamburger sushi, maybe. Two novel experiences, life can only get so interesting. This is all familiar idea which needs no explanation. Outdoor seating at a cafe. Picnics. This is not unusual desire, I think, so reader understands. **And on a bike, you can go anywhere**. With some cargo attachment the food comes along just fine. Why trouble to awaken the 26 year old 4000 pound beast on four wheels just for this? Wrong tool for the job. What about a 20 pound bicycle with a lunch box? Ok, exactly.
I note possible interference from pedal stroke. Mount it, tighten it. It is too easy. I find a cooler matching the inner dimensions of the basket. Perfect match. 12 cans of beverage? Of course.
- `Wald 582 Side-Mount Folding Rear Basket (Silver)`
- `Ozark Trail 12-Can Soft-Sided Cooler with Removable Hard Liner (Red)`
✅ Done.
@@ -0,0 +1,66 @@
+++
categories = ["plants"]
date = 2026-05-31T10:00:00-06:00
description = ""
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" >}}
@@ -0,0 +1,135 @@
+++
categories = ["build","software"]
date = 2026-07-07T10:00:00-05:00
description = ""
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.
@@ -0,0 +1,59 @@
+++
categories = ["food"]
tags = ["reverse-engineer","arbys","sauce"]
date = 2025-10-04T13:00:00-05:00
description = ""
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,7 +1,7 @@
+++
categories = ["build"]
tags = ["bicycle"]
date = 2025-08-07T08:00:00-05:00
date = 2025-08-12T12:15:00-05:00
description = ""
draft = false
slug = "bicycle-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 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.**
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.**
## 🔦 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,11 +36,19 @@ 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" >}}
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.
**This will do**.
---
## 🧰 Case
@@ -62,7 +70,7 @@ Since the headlamp consumes 48 W, I need a battery that can sustain this load fo
- $t$ = run time (hr)
- $E_\text{battery}$ = battery energy (Wh)
- $p_\text{load}$ = LED headlamp power (W)
- $P_\text{load}$ = LED headlamp power (W)
$$
t = \frac{12.8 \text{ V} \cdot 16 \text{ Ah}}{48 \text{ W}} = 4.27 \text{ hr}
@@ -113,6 +121,10 @@ Now for final assembly. This is all very simple and I ran into no issues.
- 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.
{{< image src="images/bicycle-headlamp-wiring-diagram.svg" >}}
@@ -124,16 +136,32 @@ Here is a **wiring diagram** of the circuit.
| SW1 | SPST Switch |
| LED1 | 48 W |
I strap the battery box to the rear cargo rack with hook and loop straps.
That is all.
{{< image
src="images/battery-box-power-port.JPG"
caption="Battery box power port" >}}
--
{{< image
src="images/battery-box-switch.JPG"
caption="Battery box switch" >}}
{{< 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!**
@@ -0,0 +1,207 @@
+++
categories = ["software"]
tags = ["router","opnsense"]
date = 2025-08-27T18:30:00-05:00
description = ""
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 -1
View File
@@ -116,7 +116,7 @@ ignoreErrors = ["error-remote-getjson", "error-missing-instagram-accesstoken"]
# whether to show the author
author = true
# Site creation time
since = 2024
since = 2021
# ICP info only in China (HTML format is supported)
icp = ""
# license info (HTML format is supported)