{"version":"1.0","encoding":"UTF-8","feed":{"xmlns":"http://www.w3.org/2005/Atom","xmlns$openSearch":"http://a9.com/-/spec/opensearchrss/1.0/","xmlns$blogger":"http://schemas.google.com/blogger/2008","xmlns$georss":"http://www.georss.org/georss","xmlns$gd":"http://schemas.google.com/g/2005","xmlns$thr":"http://purl.org/syndication/thread/1.0","id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016"},"updated":{"$t":"2026-07-06T06:45:57.668-07:00"},"category":[{"term":"featured"},{"term":"VibeEngineering"},{"term":"Backup"},{"term":"NAS"},{"term":"OMV"},{"term":"RAID"},{"term":"Raspberry PI 4"}],"title":{"type":"text","$t":"coding-mayhem"},"subtitle":{"type":"html","$t":""},"link":[{"rel":"http://schemas.google.com/g/2005#feed","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/posts\/default"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default?alt=json"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/"},{"rel":"hub","href":"http://pubsubhubbub.appspot.com/"},{"rel":"next","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default?alt=json\u0026start-index=26\u0026max-results=25"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"generator":{"version":"7.00","uri":"http://www.blogger.com","$t":"Blogger"},"openSearch$totalResults":{"$t":"28"},"openSearch$startIndex":{"$t":"1"},"openSearch$itemsPerPage":{"$t":"25"},"entry":[{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-5660438504615846278"},"published":{"$t":"2026-07-05T22:59:20.083-07:00"},"updated":{"$t":"2026-07-06T06:45:57.668-07:00"},"title":{"type":"text","$t":"Blue Iris → Home Assistant → a kitchen tablet that talks back"},"content":{"type":"html","$t":"\u003Cp\u003E\u003C\/p\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhpy0GUJ3Wg-TuknsF-HYJhHHlYNx2UqVjx8hD3Iu4HV2QT4ojw6_Cdao4Hz5gIto8uozwZHoX-g3QAm_pxbiatg2YQ-1HS4XVbE_zH4gHST2CE9kG4mkhSl4WMC7rdp7FdOdX5YnaTtNonwCN09uGjPtAQbWdO4hBei3O-PDnfgK28tFAYNtJ9acs21yE\/s1672\/kitchen-tablet.png\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"941\" data-original-width=\"1672\" height=\"180\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhpy0GUJ3Wg-TuknsF-HYJhHHlYNx2UqVjx8hD3Iu4HV2QT4ojw6_Cdao4Hz5gIto8uozwZHoX-g3QAm_pxbiatg2YQ-1HS4XVbE_zH4gHST2CE9kG4mkhSl4WMC7rdp7FdOdX5YnaTtNonwCN09uGjPtAQbWdO4hBei3O-PDnfgK28tFAYNtJ9acs21yE\/s320\/kitchen-tablet.png\" width=\"320\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cp\u003E\u003C\/p\u003E\u003Cp\u003E\u003Cstrong\u003EWhat this is:\u003C\/strong\u003E a build journal for a fully-local home-security setup — taking object-detection\nalerts from a Blue Iris NVR (backed by a self-hosted CodeProject.AI vision server), routing them\nthrough Home Assistant over MQTT, and turning them into spoken announcements and a live camera grid\non a wall-mounted kitchen tablet. No cloud and no smart-display — everything stays on the LAN. It's\none living post that grows step by step as the build progresses.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003ETLDR\u003C\/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EGoal:\u003C\/strong\u003E get Blue Iris (my Windows NVR) talking to Home Assistant, then have HA push\nalerts — with spoken announcements — to a wall-mounted Galaxy Tab A11+ acting as a\nkitchen monitor.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EKicked it off from a Gemini search\u003C\/strong\u003E asking whether an Echo Show, a Nest Hub, or\n\"some open-source device\" was the right kitchen display. Conclusion: none of the\nsmart displays — a plain Android tablet running Home Assistant Companion + Fully Kiosk\nkeeps everything local, which is the whole point of this homelab.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ETransport decision:\u003C\/strong\u003E MQTT is the spine for \u003Cem\u003Eevents\u003C\/em\u003E (BI already has an MQTT client,\nI already run Mosquitto). The Blue Iris HACS integration is optional, kept for later if\nI want to arm\/disarm BI profiles from HA. Video is a separate channel (RTSP\/MJPEG), not\nMQTT's job.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EMQTT auth:\u003C\/strong\u003E the broker currently runs \u003Ccode\u003E-c \/mosquitto-no-auth.conf\u003C\/code\u003E on purpose, with\nan open todo to fix it. Adding Blue Iris as a new client was the push to finally enable\nauth. Doing it \u003Cstrong\u003Estep by step with verification at each step\u003C\/strong\u003E rather than one big flip,\nbecause the cutover briefly takes Zigbee2MQTT + HA's MQTT offline.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESurprise find:\u003C\/strong\u003E an auth config already existed but was \u003Cstrong\u003Edead\u003C\/strong\u003E — a misnamed\n\u003Ccode\u003Emosquitto.config\u003C\/code\u003E (should be \u003Ccode\u003E.conf\u003C\/code\u003E) full of \u003Ccode\u003Eallow_anonymous false\u003C\/code\u003E settings that the\nbroker was never told to load, because the compose command still pointed at\n\u003Ccode\u003Emosquitto-no-auth.conf\u003C\/code\u003E. The exact \"config vs running command mismatch\" a past commit\nhad flagged. Renamed it, wired it up, and found the published \u003Ccode\u003E9001\u003C\/code\u003E websockets port had\nno matching listener.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EBackups first:\u003C\/strong\u003E before touching auth I added a \u003Ccode\u003Ehome_automation\/backup.sh\u003C\/code\u003E that\nsnapshots the gitignored runtime data (HA config, MQTT, Zigbee2MQTT coordinator state,\nESPHome) into a timestamped tarball — cold by default for consistency, with a \u003Ccode\u003E--hot\u003C\/code\u003E\nno-downtime option, optional offsite scp, and retention.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESecond git landmine:\u003C\/strong\u003E \u003Ccode\u003Ezigbee2mqtt\/configuration.yaml\u003C\/code\u003E is tracked in git \u003Cstrong\u003Ewith the\nZigbee \u003Ccode\u003Enetwork_key\u003C\/code\u003E sitting in it\u003C\/strong\u003E — a real secret already committed. So the MQTT\npassword went in via a gitignored \u003Ccode\u003Ez2m.env\u003C\/code\u003E (env override) instead of that file, per the\nrepo's own \"secrets via env_file\" rule. Flagged the network-key leak as a separate\nfollow-up; didn't touch it mid-cutover.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EThe flip (Step 4) landed cleanly on the first try\u003C\/strong\u003E — because of the pre-seed ordering,\nthe moment the broker switched to \u003Ccode\u003Eallow_anonymous false\u003C\/code\u003E, Zigbee2MQTT just reconnected\nauthenticated and kept publishing all ~18 devices. No re-pairing, no lost automations, no\nscramble. The careful ordering paid for itself.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EBlue Iris is wired in (Step 5):\u003C\/strong\u003E BI publishes per-camera alert JSON to MQTT, and the\nobject labels in it — \u003Ccode\u003Eperson:86%,bird:82%,...\u003C\/code\u003E — come from \u003Cstrong\u003ECodeProject.AI\u003C\/strong\u003E, a\nself-hosted vision server BI hands each triggering frame to. So the detection is local\ntoo, no cloud vision API. HA now has smart, confidence-tagged events to build on.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EHA sensors built (Step 6):\u003C\/strong\u003E three MQTT \u003Ccode\u003Ebinary_sensor\u003C\/code\u003Es — person, vehicle, animal —\nthat parse the CodeProject.AI labels out of the memo, take the highest confidence in each\ncategory, and fire above 60%. Adding the extra types was trivial once the person one\nworked: same template, different label list.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EThird landmine, surfaced by a config check:\u003C\/strong\u003E a long-dead \u003Ccode\u003Epanel_iframe:\u003C\/code\u003E block (a\nremoved HA integration, \u003Cem\u003Eand\u003C\/em\u003E a broken double-\u003Ccode\u003Ehttp:\/\/\u003C\/code\u003E URL pointing at the wrong port)\nhad been sitting in \u003Ccode\u003Econfiguration.yaml\u003C\/code\u003E, silently failing. Removed it; logged a todo to\nre-add an \"MQTT Explorer\" link the modern way (a dashboard Webpage card) if wanted.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EKitchen tablet started (Step 7):\u003C\/strong\u003E made a \u003Cstrong\u003Ededicated, non-admin, local-only HA user\u003C\/strong\u003E\nfor the wall tablet (so a physically-exposed, always-logged-in device can't wield my admin\naccount), and registered the Galaxy Tab A11+ in the HA Companion app as \u003Ccode\u003EKitchen Tablet\u003C\/code\u003E.\nFully Kiosk + TTS + the actual announcement are the remaining pieces.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFully Kiosk wired in (Step 8) — but not before a self-inflicted flop:\u003C\/strong\u003E I designed this as\n\"REST + MQTT\" and even provisioned a \u003Ccode\u003Etablet\u003C\/code\u003E broker user, on the assumption the HA\nintegration would consume Fully Kiosk's MQTT for instant push. It doesn't — the official\nintegration is \u003Cstrong\u003EREST\/polling only\u003C\/strong\u003E, and Fully Kiosk MQTT is a \u003Cem\u003Eseparate\u003C\/em\u003E channel that just\nspawns duplicate entities. Once I dropped a battery-\"unplugged\" alert I didn't need, MQTT\nbought this project nothing. \u003Cstrong\u003EKilled it, went REST-only\u003C\/strong\u003E, and the two things the\nannouncement needs — a screen switch that wakes the tablet and a \u003Ccode\u003Emedia_player\u003C\/code\u003E — came up\nclean. Lesson: read the integration's contract \u003Cem\u003Ebefore\u003C\/em\u003E building infra for it.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EIt talks (Step 9):\u003C\/strong\u003E wired up \u003Cstrong\u003EGoogle Translate TTS\u003C\/strong\u003E (free, zero-setup, cloud — to prove\nthe chain fast before swapping to local Piper) and wrote the automation: garage-person sensor\n→ wake the tablet screen → volume 0.8 → speak \u003Cem\u003E\"Person entering driveway.\"\u003C\/em\u003E, with a 10s\ncooldown. One snag — Fully Kiosk threw \u003Cem\u003E\"wrong url or unsupported sound\"\u003C\/em\u003E because HA's internal\nURL wasn't tablet-reachable; setting it to \u003Ccode\u003Ehttp:\/\/192.168.4.2:8123\u003C\/code\u003E fixed it, and the tablet\nspoke on the next run.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EIt shows, too (Step 10) — mostly:\u003C\/strong\u003E generalized to all three front cameras and added a 3-across\ngrid that pops on the tablet on any detection (person\/vehicle\/animal), driven by a\n\u003Ccode\u003Etimer.front_alert\u003C\/code\u003E + a conditional dashboard card — zero HACS. Folded the announcement into one\nper-camera automation (\"Person at the driveway \/ front door \/ front yard.\"). The HA YAML was the\neasy part; the fight was \u003Cstrong\u003Einfra plumbing\u003C\/strong\u003E — Blue Iris serves RTSP on port \u003Cstrong\u003E81\u003C\/strong\u003E not 554, and\nHA→cameras crossed two \u003Cstrong\u003EpfSense VLANs\u003C\/strong\u003E that had to be opened with one firewall rule. Also\nre-learned that the Step-7 non-admin tablet user (by design) means dashboards are built from\nadmin, not on the tablet. \u003Cstrong\u003EOpen issue:\u003C\/strong\u003E the grid tiles currently show a \u003Cstrong\u003Estatic snapshot, not\nlive video\u003C\/strong\u003E — go2rtc keeps dropping to MJPEG (proved HA \u003Cem\u003Ecan\u003C\/em\u003E pull the stream over TCP; prime\nsuspect is the cameras' 8 kHz AAC audio breaking WebRTC\/MSE). Being solved next.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EEnd state:\u003C\/strong\u003E the project's whole point is \u003Cstrong\u003Eworking end-to-end\u003C\/strong\u003E — camera → CodeProject.AI →\nMQTT → HA → a wall tablet that \u003Cem\u003Eannounces\u003C\/em\u003E whoever's out front (and pops a camera grid; live\ntiles still being sorted), all local. Left to do: get the grid live, then swap Google TTS → Piper\nfor a fully-local voice.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003EThe goal\u003C\/h2\u003E\n\u003Cp\u003EI run Blue Iris as my home DVR. I want two things out of it beyond just recording:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cstrong\u003EAlerts land in Home Assistant\u003C\/strong\u003E so I can build automations on top of camera events\n(person at the front door, motion in the driveway, etc.).\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EA dedicated kitchen monitor\u003C\/strong\u003E — a Galaxy Tab A11+ — that shows the cameras and, more\nimportantly, \u003Cem\u003Eannounces\u003C\/em\u003E alerts out loud so I don't have to be looking at a screen.\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cp\u003EI started by asking Gemini whether the kitchen display should be an Alexa Echo Show, a\nGoogle\/Nest device, or something open-source. That framed the first real decision.\u003C\/p\u003E\n\u003Ch2\u003EDecisions made (and why)\u003C\/h2\u003E\n\u003Ch3\u003ENot an Echo Show or a Nest Hub\u003C\/h3\u003E\n\u003Cp\u003EBoth are cloud-tethered. This homelab is deliberately local and VPN-only — Home Assistant,\nMQTT, Zigbee2MQTT and ESPHome all sit on a private network with no cloud dependency, and\nremote access is a WireGuard tunnel rather than anything internet-facing. Dropping an\nAmazon or Google display into the middle of the kitchen would drag exactly the cloud\ndependency I've avoided everywhere else back in. So: a plain \u003Cstrong\u003EAndroid tablet\u003C\/strong\u003E running the\n\u003Cstrong\u003EHome Assistant Companion app + Fully Kiosk Browser\u003C\/strong\u003E. HA can push it text-to-speech\nannouncements, wake the screen, and pop up the triggering camera — all on-LAN.\u003C\/p\u003E\n\u003Ch3\u003EMQTT as the event spine, HACS integration optional\u003C\/h3\u003E\n\u003Cp\u003EI went back and forth on how Blue Iris should talk to HA. Two candidates:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EMQTT\u003C\/strong\u003E — Blue Iris \u003Cem\u003Epushes\u003C\/em\u003E an alert the instant it fires to a topic like\n\u003Ccode\u003EBlueIris\/FrontDoor\/Status\u003C\/code\u003E; HA subscribes and turns it into a \u003Ccode\u003Ebinary_sensor\u003C\/code\u003E. Real-time,\nand it runs on the Mosquitto broker I already have.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EThe Blue Iris HACS integration\u003C\/strong\u003E — a community \u003Ccode\u003Ecustom_component\u003C\/code\u003E that \u003Cem\u003Epolls\u003C\/em\u003E BI's JSON\nAPI and mirrors camera state as entities. Good for control\/status, laggier for instant\nalerts, and it's a third-party dependency to maintain.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003EThey're not really competitors — they do different jobs. So the call was: \u003Cstrong\u003EMQTT is the\nspine\u003C\/strong\u003E for events (it drives the \"announce it now\" behaviour), and the HACS integration is\nan \u003Cem\u003Eoptional\u003C\/em\u003E convenience layer I'll add only if I later want to arm\/disarm BI profiles from\ninside HA. Video is neither of these — that's RTSP\/MJPEG substreams pulled into HA, handled\nseparately.\u003C\/p\u003E\n\u003Ch3\u003EEnable MQTT auth — now, but step by step\u003C\/h3\u003E\n\u003Cp\u003EThe broker currently runs \u003Ccode\u003Emosquitto -c \/mosquitto-no-auth.conf\u003C\/code\u003E. That was a deliberate\nshortcut with a standing todo to fix it. The real exposure isn't Blue Iris (which only\npublishes) — it's that \u003Cstrong\u003Eanything on the LAN can publish to Zigbee2MQTT and control my\nZigbee devices\u003C\/strong\u003E, and read all home state, with no credentials. That's fine right up until\nsomething hostile gets on the LAN.\u003C\/p\u003E\n\u003Cp\u003EAdding Blue Iris as a new MQTT client was the nudge to finally close that todo. The catch:\nthe cutover to \u003Ccode\u003Eallow_anonymous false\u003C\/code\u003E briefly knocks HA's MQTT and Zigbee2MQTT offline\nuntil each client has its new credentials — so if I fat-finger a password, existing\nautomations go dark. Decision: \u003Cstrong\u003Eenable auth, but incrementally, verifying after each step\u003C\/strong\u003E\nrather than flipping everything at once.\u003C\/p\u003E\n\u003Ch2\u003EArchitecture (target)\u003C\/h2\u003E\n\u003Cp\u003ETwo independent channels:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cstrong\u003EEvents:\u003C\/strong\u003E Blue Iris → MQTT (authenticated Mosquitto) → HA \u003Ccode\u003Ebinary_sensor\u003C\/code\u003Es →\nautomations.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EVideo:\u003C\/strong\u003E Blue Iris RTSP\/MJPEG substreams → HA (via go2rtc\/WebRTC for smooth playback)\n→ dashboard cards + snapshot-on-alert.\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cp\u003EThen the payoff: HA automation fires → TTS announcement on the kitchen tablet + wake screen\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003Eshow the triggering camera.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Ch2\u003ESafety net: a backup script first\u003C\/h2\u003E\n\u003Cp\u003EBefore flipping any auth, I wanted a one-command rollback point. The stack's real state\nisn't in git — the \u003Ccode\u003Ecompose.yaml\u003C\/code\u003E is, but the data it mounts (HA config, MQTT config +\npersistence, Zigbee2MQTT's coordinator state, ESPHome) is all gitignored and lives only on\nthe server. The scary one is \u003Ccode\u003Ezigbee2mqtt\/data\/\u003C\/code\u003E: lose the coordinator state \/ network key\nand you re-pair every Zigbee device by hand.\u003C\/p\u003E\n\u003Cp\u003ESo I wrote \u003Ccode\u003Ehome_automation\/backup.sh\u003C\/code\u003E. It tars the four data directories into a timestamped\narchive:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-bash\"\u003E.\/backup.sh          # cold: docker compose stop -\u0026gt; tar -\u0026gt; start (consistent snapshot)\n.\/backup.sh --hot    # no downtime, may catch a DB mid-write\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003ECold is the default because SQLite (HA history) and the Z2M database can be mid-write during\na hot copy, and the stack restarts in seconds anyway. It skips any directory that isn't\npresent rather than failing, keeps the newest \u003Ccode\u003EKEEP\u003C\/code\u003E archives (default 7), and will scp\noffsite if \u003Ccode\u003EOFFSITE\u003C\/code\u003E is set — because a backup on the same disk isn't really a backup. HA's\nown Settings → System → Backups is a nice bonus copy, but on a Container install it only\ncovers HA itself, so the tarball is the real net.\u003C\/p\u003E\n\u003Ch2\u003EEnabling MQTT auth, step by step\u003C\/h2\u003E\n\u003Cp\u003EI decided to do the cutover as discrete, individually-verified steps rather than one big\nflip, in this order so existing automations never go dark:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003EFile prep (local) → 2. create passwd users (broker still no-auth) → 3. pre-seed HA + Z2M\ncredentials while anonymous is still allowed → 4. flip the broker to auth + restart →\u003C\/li\u003E\n\u003Cli\u003Eadd Blue Iris. The broker only starts \u003Cem\u003Erequiring\u003C\/em\u003E auth in step 4, by which point every\nexisting client already has credentials.\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Ch3\u003EStep 1 — file prep (done)\u003C\/h3\u003E\n\u003Cp\u003EPoking at the actual files turned up the dead auth config described above. The fixes:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cp\u003E\u003Ccode\u003Egit mv mqtt\/config\/mosquitto.config mqtt\/config\/mosquitto.conf\u003C\/code\u003E — so a\n\u003Ccode\u003Emosquitto -c \/mosquitto\/config\/mosquitto.conf\u003C\/code\u003E command will actually find it.\u003C\/p\u003E\n\u003C\/li\u003E\n\u003Cli\u003E\u003Cp\u003EAdded the missing websockets listener so the config matches the port compose already\npublishes:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-conf\"\u003E# MQTT over websockets (compose publishes 9001)\nlistener 9001\nprotocol websockets\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003C\/li\u003E\n\u003Cli\u003E\u003Cp\u003EAdded \u003Ccode\u003Ehome_automation\/mqtt\/config\/passwd\u003C\/code\u003E to \u003Ccode\u003E.gitignore\u003C\/code\u003E. The passwd file holds\npassword \u003Cem\u003Ehashes\u003C\/em\u003E, not plaintext, but it's still not something to push to a repo.\u003C\/p\u003E\n\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003ENo broker change in this step — it's still literally running \u003Ccode\u003E-c \/mosquitto-no-auth.conf\u003C\/code\u003E.\nZero downtime.\u003C\/p\u003E\n\u003Ch3\u003EStep 2 — create the passwd users (done)\u003C\/h3\u003E\n\u003Cp\u003EChose \u003Cstrong\u003Eper-client users\u003C\/strong\u003E (\u003Ccode\u003Eha\u003C\/code\u003E, \u003Ccode\u003Ez2m\u003C\/code\u003E, \u003Ccode\u003Eblueiris\u003C\/code\u003E) over one shared account, so any single\nclient can be rotated or revoked without disturbing the others. The commands \u003Ccode\u003Eexec\u003C\/code\u003E into the\nstill-running (no-auth) mqtt container and write the passwd file the broker isn't reading\nyet — so still no downtime:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-bash\"\u003Edocker compose exec mqtt mosquitto_passwd -c \/mosquitto\/config\/passwd ha\ndocker compose exec mqtt mosquitto_passwd    \/mosquitto\/config\/passwd z2m\ndocker compose exec mqtt mosquitto_passwd    \/mosquitto\/config\/passwd blueiris\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EOnly the first gets \u003Ccode\u003E-c\u003C\/code\u003E (create) — \u003Ccode\u003E-c\u003C\/code\u003E on a later line would wipe the earlier users.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe gotcha I'd flagged actually fired\u003C\/strong\u003E, right on cue:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EWarning: File \/mosquitto\/config\/passwd has world readable permissions.\nFuture versions will refuse to load this file.\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003E\u003Ccode\u003Emosquitto_passwd\u003C\/code\u003E created the file world-readable. Fixed it before Step 4 so the broker\nloads cleanly — and made sure the \u003Ccode\u003Emosquitto\u003C\/code\u003E user still \u003Cem\u003Eowns\u003C\/em\u003E it, otherwise locking it to\nowner-only would lock the broker itself out:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-bash\"\u003Edocker compose exec -u root mqtt sh -c \"chown mosquitto:mosquitto \/mosquitto\/config\/passwd \u0026amp;\u0026amp; chmod 0700 \/mosquitto\/config\/passwd \u0026amp;\u0026amp; ls -l \/mosquitto\/config\/passwd\"\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003E\u003Cstrong\u003EOperational note:\u003C\/strong\u003E this server needs \u003Ccode\u003Esudo\u003C\/code\u003E on every Docker command\n(\u003Ccode\u003Esudo docker compose …\u003C\/code\u003E). Nothing about the plan changes — just a prefix — but it's why the\nfirst \u003Ccode\u003Ebackup.sh\u003C\/code\u003E run landed in \u003Ccode\u003E\/root\/…\u003C\/code\u003E before I hardcoded the backup path.\u003C\/p\u003E\n\u003Ch3\u003EStep 3 — pre-seed HA + Z2M credentials (broker still anonymous)\u003C\/h3\u003E\n\u003Cp\u003EThe whole point of the ordering: give every existing client its credentials \u003Cem\u003Ebefore\u003C\/em\u003E the\nbroker starts demanding them, so nothing drops when I flip. Two different clients, two\ndifferent mechanisms:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EZigbee2MQTT\u003C\/strong\u003E — turned out its \u003Ccode\u003Econfiguration.yaml\u003C\/code\u003E is git-tracked \u003Cem\u003Eand\u003C\/em\u003E holds the\nnetwork key, so I refused to put the MQTT password there. Instead added an \u003Ccode\u003Eenv_file: z2m.env\u003C\/code\u003E to the z2m service; z2m maps \u003Ccode\u003EZIGBEE2MQTT_CONFIG_MQTT_USER\u003C\/code\u003E \/\n\u003Ccode\u003EZIGBEE2MQTT_CONFIG_MQTT_PASSWORD\u003C\/code\u003E onto its \u003Ccode\u003Emqtt.*\u003C\/code\u003E config. Committed a\n\u003Ccode\u003Ez2m.env.example\u003C\/code\u003E for documentation; the real \u003Ccode\u003Ez2m.env\u003C\/code\u003E stays on the server (gitignored).\nThen \u003Ccode\u003Edocker compose up -d zigbee2mqtt\u003C\/code\u003E — targeting \u003Cem\u003Eonly\u003C\/em\u003E z2m so the mqtt broker isn't\nrecreated and stays on its old no-auth config.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EHome Assistant\u003C\/strong\u003E — its MQTT is a UI config entry (\u003Ccode\u003E.storage\u003C\/code\u003E), not a YAML block, so no\nfile edits: Settings → Devices \u0026amp; Services → MQTT → Reconfigure, add username \u003Ccode\u003Eha\u003C\/code\u003E +\npassword.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe honest caveat I keep reminding myself:\u003C\/strong\u003E while the broker is still anonymous it will\nhappily accept these clients \u003Cem\u003Eeven with a wrong password\u003C\/em\u003E. So \"connected\" here only proves\nthe plumbing — the passwords don't actually get tested until Step 4. That's fine because the\nflip is instantly reversible, but it means Step 4 is the one to watch the logs on.\u003C\/p\u003E\n\u003Ch3\u003EStep 4 — the flip (done)\u003C\/h3\u003E\n\u003Cp\u003ETwo file edits: the compose \u003Ccode\u003Ecommand:\u003C\/code\u003E went from \u003Ccode\u003Emosquitto -c \/mosquitto-no-auth.conf\u003C\/code\u003E to\n\u003Ccode\u003Emosquitto -c \/mosquitto\/config\/mosquitto.conf\u003C\/code\u003E, and I added \u003Ccode\u003Elog_dest stdout\u003C\/code\u003E to the config\nso \u003Ccode\u003Edocker compose logs mqtt\u003C\/code\u003E would actually show output during the cutover (it was only\nlogging to a file). scp'd both up, deleted the stale old-named \u003Ccode\u003Emosquitto.config\u003C\/code\u003E on the\nserver, then recreated \u003Cstrong\u003Eonly\u003C\/strong\u003E the broker:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-bash\"\u003Esudo docker compose up -d mqtt\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe verification I actually trust: Zigbee2MQTT reconnecting. With the broker now refusing\nanonymous clients, z2m connecting at all \u003Cem\u003Eis\u003C\/em\u003E the proof its credentials are right:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003Ezigbee2mqtt-1  | [2026-07-05 10:18:31] info: z2m: Connected to MQTT server\nzigbee2mqtt-1  | [2026-07-05 10:18:31] info: z2m:mqtt: MQTT publish: topic\n                 'zigbee2mqtt\/bridge\/state', payload '{\"state\":\"online\"}'\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003E...followed by every device republishing its state (lights, power plugs, the kitchen door\nsensor, the laundry water-leak sensor, the grow lights). Anonymous connections now get\n\u003Ccode\u003EConnection Refused: not authorised\u003C\/code\u003E; correct credentials publish fine. The pre-seed\nordering meant this was a non-event — exactly the goal.\u003C\/p\u003E\n\u003Ch3\u003EStep 5 — Blue Iris publishing, powered by CodeProject.AI (done)\u003C\/h3\u003E\n\u003Cp\u003ESet up BI's global MQTT (server LAN IP:1883, user \u003Ccode\u003Eblueiris\u003C\/code\u003E) and a per-camera alert action\npublishing JSON to \u003Ccode\u003EBlueIris\/\u0026lt;cam\u0026gt;\/alert\u003C\/code\u003E. First message across the wire:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EBlueIris\/GarageWide\/alert\n{\"camera\":\"GarWid\",\"type\":\"Motion_A\",\"memo\":\"person:86%,bird:82%,Bicycle:78%,...\"}\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe \u003Ccode\u003Ememo\u003C\/code\u003E is the good part, and it comes from \u003Cstrong\u003ECodeProject.AI\u003C\/strong\u003E\n(\u003Ca href=\"https:\/\/codeproject.github.io\/codeproject.ai\/\"\u003Ehttps:\/\/codeproject.github.io\/codeproject.ai\/\u003C\/a\u003E) — a self-hosted AI server that Blue Iris\nhands each triggering frame to for object detection. It returns labels with confidence,\nBI stuffs them into the alert memo, and now they ride MQTT straight into Home Assistant.\nTwo things I like about this:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EIt's local.\u003C\/strong\u003E CodeProject.AI runs on my own hardware — no Rekognition, no Google Vision,\nnothing phoning a cloud. That's the same posture as the rest of this homelab, and it's a\nbig reason the tablet was never going to be an Echo Show.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003Etype:Motion_A\u003C\/code\u003E = motion \u003Cem\u003Ewith\u003C\/em\u003E AI.\u003C\/strong\u003E HA isn't getting \"something moved,\" it's getting\n\"something moved, and the model is 86% sure it's a person.\" That confidence is what lets\nthe automations be picky instead of spammy — and because BI already applies its own\nCodeProject.AI confidence threshold before firing, the memo is pre-filtered to things\nworth reacting to.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Ch3\u003EWhat CodeProject.AI decides for the next step\u003C\/h3\u003E\n\u003Cp\u003ESince detection is already handled (and handled \u003Cem\u003Elocally\u003C\/em\u003E, with confidence), the HA side\ndoesn't need to be clever about vision — it just needs to read the memo. So the next step is\na thin HA layer: an MQTT \u003Ccode\u003Ebinary_sensor\u003C\/code\u003E per camera that flips on when the memo contains\n\u003Ccode\u003Eperson\u003C\/code\u003E, with an \u003Ccode\u003Eoff_delay\u003C\/code\u003E to auto-reset (BI sends an event, not a lasting state). Person\nis the primary trigger; vehicle\/animal are easy to add later by matching different labels.\nCodeProject.AI's face-recognition and ALPR modules are a tempting future layer (known-person\nvs stranger, license plates) but out of scope for the first pass.\u003C\/p\u003E\n\u003Ch3\u003EStep 6 — Home Assistant sensors from the detections (done)\u003C\/h3\u003E\n\u003Cp\u003EAdded three MQTT \u003Ccode\u003Ebinary_sensor\u003C\/code\u003Es to \u003Ccode\u003Econfiguration.yaml\u003C\/code\u003E, one per category, all reading the\ngarage cam's \u003Ccode\u003EBlueIris\/GarageWide\/alert\u003C\/code\u003E topic:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003Eperson\u003C\/strong\u003E — labels \u003Ccode\u003E[person]\u003C\/code\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003Evehicle\u003C\/strong\u003E — labels \u003Ccode\u003E[car, truck, bus, motorcycle, motorbike, bicycle]\u003C\/code\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003Eanimal\u003C\/strong\u003E — labels \u003Ccode\u003E[bird, cat, dog, horse, sheep, cow, bear, deer, raccoon, fox, squirrel]\u003C\/code\u003E\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003EEach one walks the comma-separated memo, pulls the confidence for any label in its category,\nkeeps the highest, and reports \u003Ccode\u003Eon\u003C\/code\u003E if it's ≥ 60%. \u003Ccode\u003Eoff_delay: 30\u003C\/code\u003E auto-resets them because\nBlue Iris publishes a momentary event, not a lasting state. The nice part: once the person\nsensor's template worked, vehicle and animal were copy-paste with a different label list —\nthe CodeProject.AI memo does all the real work.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe config-check landmine.\u003C\/strong\u003E Before any of this would load, \u003Ccode\u003ECheck Configuration\u003C\/code\u003E failed\nwith \u003Ccode\u003EIntegration 'panel_iframe' not found\u003C\/code\u003E. That block had nothing to do with my changes —\n\u003Ccode\u003Epanel_iframe\u003C\/code\u003E was removed from Home Assistant a while back, and the entry had been sitting\nthere silently broken (it even had a double-\u003Ccode\u003Ehttp:\/\/\u003C\/code\u003E URL pointing at the Zigbee2MQTT port,\nnot an MQTT explorer that doesn't run here). Removed it and wrote a todo to re-add such a\nlink the modern way (a dashboard \u003Cem\u003EWebpage\u003C\/em\u003E card) if it's ever wanted. Classic case of a\n\"simple\" edit forcing you to clean up someone-past-me's mess first.\u003C\/p\u003E\n\u003Ch3\u003EStep 7 — The kitchen tablet (in progress)\u003C\/h3\u003E\n\u003Cp\u003EThe whole reason for the project. Before installing anything I made a \u003Cstrong\u003Ededicated Home\nAssistant user\u003C\/strong\u003E for the tablet — non-admin, and set to \"can only log in from the local\nnetwork.\" A wall-mounted tablet is a physically-exposed device that stays logged in forever;\nit should never be carrying my admin credentials, and binding it to its own user means I can\nrevoke it wholesale (and its long-lived tokens) if it's ever lost, without touching my\naccount.\u003C\/p\u003E\n\u003Cp\u003EThen installed the \u003Cstrong\u003EHome Assistant Companion app\u003C\/strong\u003E on the Galaxy Tab A11+ and signed in as\nthat user — it registered cleanly as \u003Ccode\u003EKitchen Tablet\u003C\/code\u003E (denying location, since a wall tablet\ndoesn't need presence). That gives a \u003Ccode\u003Enotify.mobile_app_kitchen_tablet\u003C\/code\u003E service and the usual\ndevice sensors.\u003C\/p\u003E\n\u003Cp\u003EStopped here for the session. Remaining tablet work:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EFully Kiosk Browser\u003C\/strong\u003E + its HA integration → gives a \u003Ccode\u003Emedia_player.\u0026lt;tablet\u0026gt;\u003C\/code\u003E and a\nscreen-on switch (what an announcement actually needs: wake + speak).\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EA TTS engine\u003C\/strong\u003E — leaning Google Translate TTS to prove it fast, then Piper for a fully\nlocal voice consistent with the rest of the homelab.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EThe automation:\u003C\/strong\u003E \u003Ccode\u003Ebinary_sensor.garage_wide_person\u003C\/code\u003E → on → wake screen + announce.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Ch3\u003EStep 8 — The tablet's control surfaces: Fully Kiosk (REST-only, done)\u003C\/h3\u003E\n\u003Cp\u003EStep 7 \u003Cem\u003Eonboarded\u003C\/em\u003E the tablet; Step 8 gives Home Assistant real \u003Cem\u003Econtrol\u003C\/em\u003E of it. An\nannouncement needs two things the Companion app doesn't provide: a way to \u003Cstrong\u003Eturn the screen\non\u003C\/strong\u003E (a wall tablet is asleep most of the day) and a \u003Cstrong\u003E\u003Ccode\u003Emedia_player\u003C\/code\u003E\u003C\/strong\u003E to speak through.\nBoth come from \u003Cstrong\u003EFully Kiosk Browser\u003C\/strong\u003E — the kiosk app that pins the tablet to the HA\ndashboard — through its official HA integration.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDecision — pay for Fully Kiosk Plus.\u003C\/strong\u003E The free version's remote admin is too thin; the\nintegration's screen switch and \u003Ccode\u003Emedia_player\u003C\/code\u003E lean on Plus features. One-time ~€8 per\ndevice, and this is \u003Cem\u003Ethe\u003C\/em\u003E device the whole project exists for — easy yes.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe MQTT flop.\u003C\/strong\u003E I nearly over-built this step. My first plan was \"REST \u003Cem\u003Eand\u003C\/em\u003E MQTT\": HA\nwould drive the tablet over Fully Kiosk's REST admin API, \u003Cem\u003Eand\u003C\/em\u003E Fully Kiosk would publish\nstate to my Mosquitto broker so HA got instant push instead of ~30s polling. I talked myself\nthrough what MQTT buys with the tablet camera left out — push state, an instant \"unplugged\"\nalert — decided it was \"cheap now, annoying to retrofit,\" and went and \u003Cstrong\u003Ecreated a \u003Ccode\u003Etablet\u003C\/code\u003E\nbroker user\u003C\/strong\u003E for it (the same careful SIGHUP-reload dance as the Step 4 cutover; the\n\u003Ccode\u003Emosquitto_passwd\u003C\/code\u003E also threw a \"file not owned by root\" warning, which turned out to be a\nnon-fatal future-version nag — the broker runs as the \u003Ccode\u003Emosquitto\u003C\/code\u003E user, so \u003Ccode\u003Echown root\u003C\/code\u003E\nwould actually \u003Cem\u003Ebreak\u003C\/em\u003E it, and I left it alone).\u003C\/p\u003E\n\u003Cp\u003EThen I read the integration docs \u003Cem\u003Ebefore\u003C\/em\u003E configuring the tablet, and the premise fell apart.\n\u003Cstrong\u003EThe official Fully Kiosk integration is REST\/polling only — it does not consume MQTT.\u003C\/strong\u003E\nFully Kiosk \u003Cem\u003Ecan\u003C\/em\u003E publish to MQTT, but on the HA side that's a \u003Cem\u003Eseparate\u003C\/em\u003E channel: you either\nenable Fully Kiosk's MQTT auto-discovery (which spawns a \u003Cstrong\u003Esecond, parallel set of entities\u003C\/strong\u003E\nbeside the REST ones) or hand-write MQTT sensors in YAML. It was never \"the same entities,\njust faster.\" And the two things the announcement actually needs — the \u003Ccode\u003Emedia_player\u003C\/code\u003E and the\nscreen switch — come from the REST integration \u003Cem\u003Eregardless\u003C\/em\u003E of MQTT.\u003C\/p\u003E\n\u003Cp\u003ESo I asked what push would even buy once I dropped the unplugged alert: nothing this project\nconsumes. MQTT push only speeds up HA learning about \u003Cem\u003Etablet-originated\u003C\/em\u003E state (screen tapped,\nscreensaver started), and I have no automation that triggers on any of that. Commands HA\n\u003Cem\u003Esends\u003C\/em\u003E (wake, speak) are REST either way and fire instantly. Faster updates of states nothing\nreads, in exchange for a duplicate entity set to maintain — cost with no benefit. \u003Cstrong\u003EKilled it.\nStep 8 is REST-only.\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003Etablet\u003C\/code\u003E broker user isn't wasted — it sits idle, ready for the day I want a\ntablet-state-triggered automation and flip on Fully Kiosk's MQTT discovery then. Lesson\n(re)learned: read an integration's actual contract \u003Cem\u003Ebefore\u003C\/em\u003E provisioning infrastructure for\nit. The entire MQTT limb got built on an assumption a two-minute docs read would have killed.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe plan, REST-only:\u003C\/strong\u003E\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cstrong\u003ETablet\u003C\/strong\u003E — activate Plus, point Fully Kiosk's start URL at the HA dashboard signed in as\nthe \u003Ccode\u003EKitchen Tablet\u003C\/code\u003E user, enable Remote Administration + a password (the port-2323 admin\npassword HA authenticates with), set kiosk behavior (stay awake on charge, screen-off\ntimeout, camera\/motion off). No MQTT.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EHome Assistant\u003C\/strong\u003E — add the \u003Cem\u003EFully Kiosk Browser\u003C\/em\u003E integration (tablet IP + port 2323 + the\nadmin password). It spins up \u003Ccode\u003Emedia_player.kitchen_tablet\u003C\/code\u003E, the screen switch, and\nbattery\/storage\/connectivity sensors, plus a TTS service and custom services (launch app,\nload URL).\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cp\u003E\u003Cstrong\u003EDone.\u003C\/strong\u003E Added the integration against \u003Ccode\u003E192.168.3.54:2323\u003C\/code\u003E; it created the device and — the\none gotcha — named its entities after the tablet's Fully Kiosk device name (\"Blue Iris Alert's\nTab A11\"), so the announcement output landed as \u003Ccode\u003Emedia_player.blue_iris_alerts_s_tab_a11\u003C\/code\u003E, not\nthe tidy \u003Ccode\u003Ekitchen_tablet\u003C\/code\u003E I'd assumed in the plan. The \u003Cstrong\u003Escreen switch physically wakes and\nsleeps the tablet from HA\u003C\/strong\u003E — the one proof that actually matters — and the \u003Ccode\u003Emedia_player\u003C\/code\u003E is\nlive. That's the whole control surface the announcement needs, and it came together with \u003Cem\u003Ezero\u003C\/em\u003E\nbroker involvement, which is the flop above paying its dividend. The voice (a TTS engine) and\nthe \"garage person → wake + speak\" automation are Step 9.\u003C\/p\u003E\n\u003Ch3\u003EStep 9 — Giving it a voice: the announcement (Google TTS)\u003C\/h3\u003E\n\u003Cp\u003EThe payoff. Two pieces: pick a text-to-speech engine, then write the automation that ties the\nwhole pipeline to the tablet.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ETTS choice — Google first, Piper next.\u003C\/strong\u003E Both are free. \u003Cstrong\u003EPiper\u003C\/strong\u003E is the on-brand answer — a\nlocal neural TTS engine, nothing leaves the LAN, consistent with the whole all-local thesis —\nbut it needs a container + the Wyoming integration stood up. \u003Cstrong\u003EGoogle Translate TTS\u003C\/strong\u003E is built\ninto HA with zero setup (it \u003Cem\u003Eis\u003C\/em\u003E a cloud call, a snippet of text per announcement), so I used it\nto \u003Cstrong\u003Eprove the chain end-to-end first\u003C\/strong\u003E, with a clean swap to Piper as the follow-up. Adding it\nwas one click: \u003Cem\u003EAdd Integration → Google Translate Text-to-Speech\u003C\/em\u003E → \u003Ccode\u003Etts.google_translate_en_com\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe automation\u003C\/strong\u003E (hand-written into the git-tracked \u003Ccode\u003Eautomations.yaml\u003C\/code\u003E, scp'd to the server,\nreloaded): on \u003Ccode\u003Ebinary_sensor.garage_wide_person\u003C\/code\u003E → \u003Cstrong\u003Eon\u003C\/strong\u003E, it wakes the tablet screen\n(\u003Ccode\u003Eswitch.blue_iris_alerts_s_tab_a11_screen\u003C\/code\u003E), sets volume to \u003Cstrong\u003E0.8\u003C\/strong\u003E so a quiet tablet doesn't\nswallow it, and speaks \u003Cstrong\u003E\"Person entering driveway.\"\u003C\/strong\u003E through\n\u003Ccode\u003Emedia_player.blue_iris_alerts_s_tab_a11\u003C\/code\u003E. A trailing \u003Ccode\u003Edelay: 10s\u003C\/code\u003E with \u003Ccode\u003Emode: single\u003C\/code\u003E gives a\n~10-second cooldown so a flapping sensor doesn't announce on repeat.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe one snag — \"wrong url or unsupported sound.\"\u003C\/strong\u003E First test, the tablet threw exactly that.\nIt's the classic Fully-Kiosk-TTS trap: the error comes from \u003Cem\u003EFully Kiosk\u003C\/em\u003E, not TTS — the audio\nrendered fine, but HA had handed the tablet a \u003Cstrong\u003Esound URL it couldn't reach\u003C\/strong\u003E. HA builds the TTS\nproxy URL from its configured \u003Cem\u003Einternal URL\u003C\/em\u003E, which wasn't a tablet-reachable address. Fix:\nSettings → System → Network → set the internal URL to \u003Ccode\u003Ehttp:\/\/192.168.4.2:8123\u003C\/code\u003E (the LAN address\nthe tablet already uses). Re-ran it and the kitchen tablet said, out loud, \u003Cem\u003E\"Person entering\ndriveway.\"\u003C\/em\u003E\u003C\/p\u003E\n\u003Cp\u003EThat closes the loop the whole project was for: \u003Cstrong\u003Ecamera → CodeProject.AI → MQTT → HA sensor →\nspoken announcement on a wall tablet\u003C\/strong\u003E, entirely local. Still to do: a real walk-into-the-driveway\ntrigger test (the manual \u003Cem\u003ERun\u003C\/em\u003E proves the actions; the sensor trigger is already proven from\nStep 6), then swap Google → \u003Cstrong\u003EPiper\u003C\/strong\u003E for a fully-local voice.\u003C\/p\u003E\n\u003Ch3\u003EStep 10 — Front-camera grid on detection (working; live video an open issue)\u003C\/h3\u003E\n\u003Cp\u003ENow that the tablet talks, the next want: when something shows up out front, don't just \u003Cem\u003Esay\u003C\/em\u003E\nit — \u003Cem\u003Eshow\u003C\/em\u003E it. Any object (person, vehicle, or animal) on any of the \u003Cstrong\u003Ethree front cameras\u003C\/strong\u003E\n(GarageWide, FrontDoor, FrontYard) now pops a live \u003Cstrong\u003E3-across grid\u003C\/strong\u003E of all three on the kitchen\ntablet, announces what it saw and where, and clears itself 30 seconds after the last alert.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDecisions (a couple were course-corrections):\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EDisplay = a conditional dashboard card, not a popup.\u003C\/strong\u003E Weighed \u003Ccode\u003Ebrowser_mod\u003C\/code\u003E (a HACS overlay\npopup, the \"standard\" way to do camera-on-detection) against a plain \u003Cstrong\u003Econditional Lovelace\ncard\u003C\/strong\u003E gated on a timer. Went conditional card — zero dependencies, fewest moving parts,\nhardest to break — in keeping with how this project has gone. A single \u003Ccode\u003Etimer.front_alert\u003C\/code\u003E\nhelper drives it: each alert restarts it (30s), the card shows while it's \u003Ccode\u003Eactive\u003C\/code\u003E.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EStreams = WebRTC via HA's built-in go2rtc\u003C\/strong\u003E, main stream — which turned out to be \u003Cstrong\u003EH.264\u003C\/strong\u003E,\nso no need for the sub-stream I'd been fretting about.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EVoice folded Step 9 in, and went per-camera.\u003C\/strong\u003E One automation now handles all three front\ncams and every object type, speaking the location: GarageWide→\"driveway\", FrontDoor→\"front\ndoor\", FrontYard→\"front yard\", prefixed with the object (\"Person at the front door.\"). Debounced\n~15s so continuous activity doesn't chatter, while the grid window still extends on every alert.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESensors generalized\u003C\/strong\u003E to all three front cams — also clearing the old \"generalize the sensors\nto other cameras\" backlog item.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe HA build was quick; the plumbing was the saga.\u003C\/strong\u003E The YAML — 6 new sensors, 2 timers, the\nmerged automation — went in and validated on the first try. Then the \u003Cem\u003Ecameras\u003C\/em\u003E turned into a\nproper homelab archaeology dig:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ERTSP short names ≠ MQTT topic names.\u003C\/strong\u003E The topic names I'd chosen (GarageWide\/FrontYard\/\nFrontDoor) aren't the Blue Iris camera short names the RTSP path needs — those were \u003Ccode\u003EGarWid\u003C\/code\u003E \/\n\u003Ccode\u003Ewwest\u003C\/code\u003E \/ \u003Ccode\u003EFrontDoor\u003C\/code\u003E, sitting in plain sight in the alert JSON's \u003Ccode\u003Ecamera\u003C\/code\u003E field the whole time.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EPort 554 was a red herring.\u003C\/strong\u003E \u003Ccode\u003Effprobe\u003C\/code\u003E timed out on 554 because \u003Cstrong\u003EBlue Iris serves RTSP on\nits web-server port (81)\u003C\/strong\u003E, not 554 — there's no separate \"enable RTSP\" toggle to hunt for.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EThe real wall: VLAN segmentation.\u003C\/strong\u003E Even on 81, HA's preview wouldn't load though my Mac\nstreamed it fine — because HA and the cameras live on \u003Cstrong\u003Edifferent pfSense VLANs\u003C\/strong\u003E (WebSrv\n\u003Ccode\u003E192.168.4.x\u003C\/code\u003E vs CAMERAS \u003Ccode\u003E192.168.6.x\u003C\/code\u003E), and the firewall blocks cross-segment by default. One\n\u003Ccode\u003Epass\u003C\/code\u003E rule (HA host → BI host, TCP), placed above the \"block home network\" rule, and a \u003Ccode\u003Ecurl\u003C\/code\u003E\nfrom HA came back \u003Ccode\u003E302\u003C\/code\u003E — streams alive. The camera-VLAN isolation is deliberate (those cheap\ncams get \u003Cem\u003Eno\u003C\/em\u003E internet and can reach nothing); I just poked a single inbound hole for HA.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EThe small stuff:\u003C\/strong\u003E the Generic Camera entities auto-named themselves \u003Ccode\u003Ecamera.192_168_6_5\u003C\/code\u003E\n(renamed to \u003Ccode\u003Ecamera.driveway\u003C\/code\u003E \/ \u003Ccode\u003Efront_door\u003C\/code\u003E \/ \u003Ccode\u003Efront_yard\u003C\/code\u003E); a dashboard \"no card type\" error\nfrom a YAML indent slip (fixed by pasting a clean full config); and — the one I should've seen\ncoming — \u003Cstrong\u003EI couldn't edit the dashboard on the tablet at all\u003C\/strong\u003E, because in Step 7 I made that\nuser non-admin \u003Cem\u003Eon purpose\u003C\/em\u003E. That's the security working, not a bug: dashboards get built from\nmy admin account, the tablet only ever views.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003E\u003Cstrong\u003EEnd result (mostly there):\u003C\/strong\u003E walk into the driveway → the tablet wakes, says \"Person at the\ndriveway,\" and the 3-camera grid pops for 30 seconds. The detection → announce → grid-pops chain\nworks end to end. The one thing not yet right: the grid tiles show a \u003Cstrong\u003Estatic snapshot, not live\nvideo\u003C\/strong\u003E — the open issue below.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EVersion-control note:\u003C\/strong\u003E the sensors\/timers\/automation are tracked YAML (committed). The camera\nentities and the dashboard live in HA's \u003Ccode\u003E.storage\u003C\/code\u003E on purpose — the RTSP URLs carry the streaming\npassword, so keeping them UI-side keeps that secret out of git.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EOpen issue — the grid tiles aren't live yet.\u003C\/strong\u003E The card pops and shows each camera, but as a\nstill image (it loads after a ~30s stall), not a moving feed. Debugging so far:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ENot the source, not the firewall.\u003C\/strong\u003E Proved HA \u003Cem\u003Ecan\u003C\/em\u003E pull the RTSP straight from the container\nover TCP (\u003Ccode\u003Effmpeg\u003C\/code\u003E counted 99 frames in 5s). The earlier cross-VLAN block was real and \u003Cem\u003Eis\u003C\/em\u003E fixed\n— HA sits on the WebSrv VLAN, the cameras on their own isolated VLAN, and one pfSense \u003Ccode\u003Epass\u003C\/code\u003E rule\nopened HA→cameras.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ENot resolution alone.\u003C\/strong\u003E The main stream is a hefty 2376×1080; a lighter 1184×536 variant (Blue\nIris \u003Ccode\u003E\u0026amp;decode=-1\u003C\/code\u003E) still came up static.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ECurrent read:\u003C\/strong\u003E go2rtc is falling back to \u003Cstrong\u003EMJPEG\u003C\/strong\u003E (its log spams broken-pipe writes to the\nbrowser) because the efficient live paths — WebRTC \/ MSE, which would push the compressed H.264\nstraight over \u003Ccode\u003E:8123\u003C\/code\u003E — aren't taking. Prime suspect: the stream's oddball \u003Cstrong\u003E8 kHz AAC audio\u003C\/strong\u003E\nbreaking MSE. Next test is stripping the audio at Blue Iris; if that's not it, the escalation is\nthe AlexxIT \u003Cstrong\u003EWebRTC\/go2rtc\u003C\/strong\u003E HACS integration for full transport\/audio control (which means\nwalking back \"no HACS,\" but only for streaming).\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESecondary quirk:\u003C\/strong\u003E on an alert the tablet sometimes reloads to the default \u003Cem\u003EOverview\u003C\/em\u003E dashboard\ninstead of staying on ours (waking the screen alone doesn't do it, so it's tied to the grid\/stream\nrender). Likely fixed by pinning Fully Kiosk's start URL to the explicit dashboard path and\nsetting that user's default dashboard.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003EParking it here — the pipeline and the pop-up work; making the tiles genuinely \u003Cem\u003Elive\u003C\/em\u003E is next\nsession's job.\u003C\/p\u003E\n\u003Ch2\u003EWhat's next\u003C\/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EFix the live grid tiles\u003C\/strong\u003E (top priority): test stripping the cameras' 8 kHz audio at Blue Iris;\nif that's not it, adopt the AlexxIT WebRTC\/go2rtc HACS integration for full stream control. Also\npin the tablet to its dashboard so an alert doesn't bounce it to Overview.\u003C\/li\u003E\n\u003Cli\u003ESwap Google TTS → \u003Cstrong\u003EPiper\u003C\/strong\u003E (local, on-brand) via a Wyoming container — no more cloud calls.\u003C\/li\u003E\n\u003Cli\u003ELater: face recognition \/ ALPR from CodeProject.AI; rotate the Zigbee network key out of git.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EBacklog:\u003C\/strong\u003E rotate the Zigbee network key out of git-tracked \u003Ccode\u003Econfiguration.yaml\u003C\/code\u003E;\nre-add the MQTT Explorer link (todo 03); maybe add CodeProject.AI face recognition \/ ALPR\nlater.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Ch2\u003EHonest take\u003C\/h2\u003E\n\u003Cp\u003EThe decisions were the hard part, and the useful move was refusing to treat \"MQTT vs the\nintegration\" as an either\/or — splitting events from video from control instead. Once I\nactually opened the files, the payoff was immediate: there was a fully-written auth config\nsitting there doing nothing because of a one-character filename mismatch and a stale compose\ncommand. Classic homelab archaeology. I'm deliberately not rushing the auth cutover — it's\nthe step most likely to silently break automations I already rely on, so it's going one\nverified step at a time.\u003C\/p\u003E\n\u003Cp\u003ELooking back at the whole session: the theme was that the \"real\" task (Blue Iris → tablet)\nkept unearthing pre-existing debt that had to be cleared first — a dead auth config, a\nnetwork key committed to git, a removed \u003Ccode\u003Epanel_iframe\u003C\/code\u003E integration silently failing a config\ncheck. None of it was the thing I set out to do, but ignoring any of it would have bitten me\nlater. The staged, verify-after-each-step approach to the MQTT cutover was the standout: zero\ndowntime, and when I flipped the broker, Zigbee2MQTT just reconnected like nothing happened.\nEnded the session with the pipeline fully working end-to-end (camera → AI → MQTT → HA sensor)\nand the tablet half-onboarded — the fun part, making it talk, is next time.\u003C\/p\u003E\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/5660438504615846278\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/07\/blue-iris-home-assistant-kitchen-tablet.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/5660438504615846278"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/5660438504615846278"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/07\/blue-iris-home-assistant-kitchen-tablet.html","title":"Blue Iris → Home Assistant → a kitchen tablet that talks back"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhpy0GUJ3Wg-TuknsF-HYJhHHlYNx2UqVjx8hD3Iu4HV2QT4ojw6_Cdao4Hz5gIto8uozwZHoX-g3QAm_pxbiatg2YQ-1HS4XVbE_zH4gHST2CE9kG4mkhSl4WMC7rdp7FdOdX5YnaTtNonwCN09uGjPtAQbWdO4hBei3O-PDnfgK28tFAYNtJ9acs21yE\/s72-c\/kitchen-tablet.png","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-3130800505579848634"},"published":{"$t":"2026-06-29T17:09:01.534-07:00"},"updated":{"$t":"2026-06-29T17:12:26.603-07:00"},"title":{"type":"text","$t":"Time Travel for Your Files: Snapshot Backups"},"content":{"type":"html","$t":"\u003Cp\u003E\u003C\/p\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEi5Fn0LuCpeWhgdmfs6CHZ8UD2ZGMJXBogXOjmN67zOW0WtAYXB-I6tBCZoa6bvhqYXgk1AhmKL-gEuc4a1-deKF8Xz6YELLk2Ie3dznKFCha0W03erPePnN5rsWPBHb0s5JHwxV89-SsC_vrIXMu3fVohdBF8GdL06CO_dRzbp_tosODm1514Sz5oCZUM\/s1536\/time_travel.png\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1024\" data-original-width=\"1536\" height=\"213\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEi5Fn0LuCpeWhgdmfs6CHZ8UD2ZGMJXBogXOjmN67zOW0WtAYXB-I6tBCZoa6bvhqYXgk1AhmKL-gEuc4a1-deKF8Xz6YELLk2Ie3dznKFCha0W03erPePnN5rsWPBHb0s5JHwxV89-SsC_vrIXMu3fVohdBF8GdL06CO_dRzbp_tosODm1514Sz5oCZUM\/s320\/time_travel.png\" width=\"320\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cbr \/\u003E\u003Cstrong\u003E\u003Cbr \/\u003E\u003C\/strong\u003E\u003Cp\u003E\u003C\/p\u003E\u003Cp\u003E\u003Cstrong\u003EStatus:\u003C\/strong\u003E Design + runbook done; pull-over-SSH backup with daily\/weekly\/monthly snapshots, files-only.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Cp\u003EA plain backup answers one question: \"is my data safe somewhere else?\" A\n\u003Cem\u003Esnapshot\u003C\/em\u003E backup answers a far more useful one: \"what did my data look like at\nany point in the past?\" The difference is the dimension of \u003Cem\u003Etime\u003C\/em\u003E. A sync or\nmirror only ever knows the present — it makes a second copy look exactly like the\nsource \u003Cem\u003Eright now\u003C\/em\u003E. Snapshots instead keep many frozen points in time, so\nyesterday's version stays untouched even after today's backup runs. That turns \"a\ncopy\" into a time machine: a way to step back to last Tuesday — before the\nmistake, before the corruption, before the ransomware. It's the whole idea, and\nit's worth understanding before any of the Docker plumbing.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EWhy this matters, in short:\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EHistory\u003C\/strong\u003E — every run is a separate point in time you can browse, not one\nmoving copy that forgets the past.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUndo\u003C\/strong\u003E — a bad change or deletion doesn't erase the good version; it's set\naside, not overwritten.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EVersion control for everything\u003C\/strong\u003E — you can actually answer \"what did this\nfile look like last Tuesday?\"\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EDamage doesn't propagate\u003C\/strong\u003E — deletions, corruption, and ransomware don't\nsilently flow downstream into the backup.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003EHere's why each of those matters, and why a sync can't give you any of them. A\nplain mirror — \u003Ccode\u003Ersync\u003C\/code\u003E with \u003Ccode\u003E--delete\u003C\/code\u003E, Dropbox, Nextcloud's own sync client —\nhas exactly one job: make the destination look like the source \u003Cem\u003Eright now\u003C\/em\u003E.\nThat's the trap. The moment you delete a file, fat-finger a \"save,\" let an app\ncorrupt a document, or a piece of ransomware encrypts the lot, the sync does its\njob perfectly and faithfully propagates the damage to the copy. The \"backup\" now\nmatches the broken source, and the good version is gone from both sides.\u003C\/p\u003E\n\u003Cp\u003EThat single failure mode is really three missing things. There is \u003Cstrong\u003Eno history\u003C\/strong\u003E:\na synced copy only knows the present, so the moment you discover the mistake,\nthere's nothing earlier to reach back to. There is \u003Cstrong\u003Eno undo\u003C\/strong\u003E: the previous-good\nstate was overwritten, not set aside. And there is \u003Cstrong\u003Eno version control\u003C\/strong\u003E: you\ncan't ask \"what did this file look like last Tuesday?\" because last Tuesday was\noverwritten by Wednesday. Snapshots add back exactly the dimension a sync throws\naway — \u003Cem\u003Etime\u003C\/em\u003E. Keeping multiple points in time, where yesterday's snapshot stays\nfrozen even after today's runs, is what turns \"a copy\" into \"a way to go back.\"\u003C\/p\u003E\n\u003Cp\u003EThe rest of this post explains how that actually works in practice, using my own\nsetup. I used to run Nextcloud on bare metal; it's now a Docker Compose stack,\nand that one change quietly broke the mental model behind my old backup. So this\nis less \"here's what I did today\" and more \"here's the handful of technologies\ninvolved and why moving Nextcloud into containers makes the backup question more\ninteresting than it looks.\" If you want the copy-paste steps, those live in a\nseparate runbook — this is the \u003Cem\u003Ewhy\u003C\/em\u003E.\u003C\/p\u003E\n\u003Ch2\u003EThe shape of the problem\u003C\/h2\u003E\n\u003Cp\u003ENextcloud is really three things glued together:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cstrong\u003EUser files\u003C\/strong\u003E — the actual photos and documents, on disk.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EA database\u003C\/strong\u003E — MariaDB, holding file metadata, shares, users, etc.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EConfig\u003C\/strong\u003E — \u003Ccode\u003Econfig.php\u003C\/code\u003E and friends.\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cp\u003EMy old backup only ever copied #1 — the user files — over SSH to a file server,\nand snapshotted them there. That was a deliberate, modest goal: \"if a drive dies,\nI can still get my documents back.\" It is \u003Cem\u003Enot\u003C\/em\u003E a \"rebuild the whole instance\"\nbackup, because the database can drift out of sync with the files you copied. I\nkept that same modest scope this time. Worth being honest about that up front:\nfiles-only means restore is \"copy the files back and re-scan,\" not \"boot a\nbyte-perfect clone.\"\u003C\/p\u003E\n\u003Ch2\u003EWhere does the data even live now? (Docker bind mounts)\u003C\/h2\u003E\n\u003Cp\u003EOn bare metal the answer was easy: \u003Ccode\u003E\/data\/nextcloud\/\u0026lt;user\u0026gt;\/files\/\u003C\/code\u003E, owned by the\nweb server user.\u003C\/p\u003E\n\u003Cp\u003EUnder Docker, the Compose file has this:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-yaml\"\u003Evolumes:\n  - .\/nextcloud_data:\/var\/www\/html\/data\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThat's a \u003Cstrong\u003Ebind mount\u003C\/strong\u003E: a directory on the host (\u003Ccode\u003E.\/nextcloud_data\u003C\/code\u003E) mapped\nstraight into the container at \u003Ccode\u003E\/var\/www\/html\/data\u003C\/code\u003E. So the files are still plain\nfiles on the host — good, that's exactly what a file backup wants. The path is\njust different now (\u003Ccode\u003E\/data\/nextcloud\/nextcloud_data\/\u0026lt;user\u0026gt;\/files\/\u003C\/code\u003E).\u003C\/p\u003E\n\u003Cp\u003EThe subtle part is \u003Cem\u003Eownership\u003C\/em\u003E. Inside the official Nextcloud image, the web\nserver runs as user \u003Ccode\u003Ewww-data\u003C\/code\u003E, which is \u003Cstrong\u003Euid\/gid 33\u003C\/strong\u003E. Bind mounts don't\ntranslate ownership — the container writes files as uid 33, so on the host those\nfiles are \u003Cem\u003Ealso\u003C\/em\u003E owned by uid 33, regardless of whether the host even has a user\nnamed \"www-data.\" On my Arch host, uid 33 maps to nothing in particular. The\nfiles just show up owned by a numeric ID. Nextcloud also writes them with tight\npermissions: directories \u003Ccode\u003E0750\u003C\/code\u003E, files \u003Ccode\u003E0640\u003C\/code\u003E. Group-readable, but not\nworld-readable.\u003C\/p\u003E\n\u003Cp\u003EThat permission detail is the whole ballgame for the backup.\u003C\/p\u003E\n\u003Ch2\u003EThe actual hard part: letting a backup user read the data\u003C\/h2\u003E\n\u003Cp\u003EMy backup pulls files over SSH using a dedicated, unprivileged account\n(\u003Ccode\u003Esyncusr\u003C\/code\u003E). For that account to read the data, it has to get past \u003Ccode\u003E0640\u003C\/code\u003E\/\u003Ccode\u003E0750\u003C\/code\u003E.\nThere were three honest options:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EOption A — join the owning group.\u003C\/strong\u003E Add \u003Ccode\u003Esyncusr\u003C\/code\u003E to the group that owns\ngid 33. Because the files are group-readable, membership alone grants read\naccess. Nothing about the data changes — Nextcloud keeps writing as uid 33,\npermissions stay exactly as the app intends, and the backup account is\n\u003Cem\u003Eread-only by construction\u003C\/em\u003E (it has group \u003Cstrong\u003Eread\u003C\/strong\u003E, not write).\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EOption B — passwordless \u003Ccode\u003Esudo rsync\u003C\/code\u003E.\u003C\/strong\u003E Let \u003Ccode\u003Esyncusr\u003C\/code\u003E run the remote copy as\nroot. Works against any permissions, but it hands a backup account\nroot-equivalent read of the whole box. That's a lot of blast radius for a\nconvenience.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EOption C — read from inside the container\u003C\/strong\u003E (\u003Ccode\u003Edocker exec\u003C\/code\u003E \/ \u003Ccode\u003Edocker cp\u003C\/code\u003E).\nCouples the backup to the container being up and to Docker's lifecycle. More\nmoving parts, more ways to fail silently.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003EI went with \u003Cstrong\u003EA\u003C\/strong\u003E. It's the smallest, most boring mechanism that does the job,\nand it preserves a nice property: the backup user \u003Cem\u003Ecannot modify the live data\u003C\/em\u003E\neven if it's compromised. On bare metal I'd achieved something similar with a\n\u003Ccode\u003Esyncgrp\u003C\/code\u003E group plus \u003Ccode\u003Echown\u003C\/code\u003E and the setgid bit — a whole dance to make new files\ninherit the right group. Docker actually made this \u003Cem\u003Esimpler\u003C\/em\u003E: the container\nalready stamps everything with gid 33 for me, so I skip the \u003Ccode\u003Echown\u003C\/code\u003E\/setgid\ngymnastics entirely and just add \u003Ccode\u003Esyncusr\u003C\/code\u003E to a group pinned at gid 33.\u003C\/p\u003E\n\u003Cp\u003EThere's one gotcha that bites people here and it has nothing to do with the data\nitself: \u003Cstrong\u003Eevery parent directory on the path has to be traversable\u003C\/strong\u003E by\n\u003Ccode\u003Esyncusr\u003C\/code\u003E. Group-read on the files is useless if \u003Ccode\u003E\/home\/someone\/\u003C\/code\u003E in the path is\n\u003Ccode\u003E0700\u003C\/code\u003E. Putting the data under \u003Ccode\u003E\/data\/...\u003C\/code\u003E (traversable) instead of a locked-down\nhome directory sidesteps it.\u003C\/p\u003E\n\u003Ch2\u003EWhy pull over SSH instead of push\u003C\/h2\u003E\n\u003Cp\u003EThe backup server reaches \u003Cem\u003Einto\u003C\/em\u003E the Nextcloud host and pulls, rather than the\nNextcloud host pushing out. This is a small security posture choice that matters:\nthe machine exposed to the internet (Nextcloud) holds no credentials to the\nbackup server. If it's ever popped, the attacker can't reach the backups, can't\ndelete them, can't ransomware them. Credentials and initiative live on the\nquieter, internal backup box. Combined with the read-only access above, the\nNextcloud host can neither reach nor corrupt its own backups.\u003C\/p\u003E\n\u003Ch2\u003Ersnapshot and the magic of hardlinks — cheap time travel\u003C\/h2\u003E\n\u003Cp\u003ESo far the time machine is just a promise. Hardlinks are what make it affordable\nenough to actually keep. The retention engine is\n\u003Ca href=\"https:\/\/rsnapshot.org\/\"\u003Ersnapshot\u003C\/a\u003E, and the thing worth understanding is \u003Cem\u003Ehow it\nkeeps \"7 daily + 4 weekly + 6 monthly\" points in time without using 17× the\ndisk\u003C\/em\u003E.\u003C\/p\u003E\n\u003Cp\u003EEach snapshot is a full, browsable directory tree — \u003Ccode\u003Edaily.0\/\u003C\/code\u003E, \u003Ccode\u003Edaily.1\/\u003C\/code\u003E, and\nso on — one self-contained destination in time you can walk into and copy from.\nBut under the hood, when a file hasn't changed between snapshots, rsnapshot\ndoesn't copy it again — it creates a \u003Cstrong\u003Ehardlink\u003C\/strong\u003E to the existing copy. A\nhardlink is just a second name pointing at the same bytes on disk. So a file that\nnever changes across six months of snapshots costs disk space \u003Cem\u003Eonce\u003C\/em\u003E, while still\nappearing in every single point in time as if it were really there. You only pay\nfor what actually changed between runs. That's what makes \"keep a year of\nmonthly snapshots\" — a whole year of dates you can travel back to — affordable\ninstead of absurd.\u003C\/p\u003E\n\u003Cp\u003EThe retention itself is declarative:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003Eretain  daily    7\nretain  weekly   4\nretain  monthly  6\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003ERun \u003Ccode\u003Ersnapshot daily\u003C\/code\u003E and it rotates \u003Ccode\u003Edaily.0 → daily.1 → … → daily.6\u003C\/code\u003E and drops\nthe oldest. \u003Ccode\u003Ersnapshot weekly\u003C\/code\u003E promotes the oldest daily into the weekly chain,\nand so on up to monthly.\u003C\/p\u003E\n\u003Ch2\u003EThe non-obvious scheduling detail\u003C\/h2\u003E\n\u003Cp\u003EI drive the three intervals with \u003Cstrong\u003Esystemd timers\u003C\/strong\u003E rather than cron (journald\nlogging, \u003Ccode\u003EPersistent=true\u003C\/code\u003E to catch up runs missed while the box was off). But\nthe genuinely non-obvious bit is \u003Cem\u003Eordering\u003C\/em\u003E: the higher-frequency interval has to\nrun \u003Cstrong\u003Elast\u003C\/strong\u003E, not first.\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003Emonthly  → 02:00 on the 1st\nweekly   → 02:30 on Mondays\ndaily    → 03:00 every day\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003Ersnapshot's promotion works by reaching into the \u003Cem\u003Elower\u003C\/em\u003E interval's oldest\nsnapshot. If \u003Ccode\u003Edaily\u003C\/code\u003E ran first and rotated away its oldest before \u003Ccode\u003Eweekly\u003C\/code\u003E got to\nlook at it, the weekly chain would grab the wrong thing. Running the bigger\nintervals at earlier times guarantees they sample the daily chain \u003Cem\u003Ebefore\u003C\/em\u003E it\nrotates. It's the kind of detail that \"works fine\" for weeks and then quietly\nproduces a wrong snapshot on the one day all three fire together (the 1st of a\nmonth that lands on a Monday).\u003C\/p\u003E\n\u003Ch2\u003ETwo small things that waste an afternoon if you miss them\u003C\/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003Ersnapshot.conf\u003C\/code\u003E is tab-delimited.\u003C\/strong\u003E Not whitespace — \u003Cem\u003Etabs\u003C\/em\u003E. Spaces produce\na config error that doesn't obviously say \"you used spaces.\" \u003Ccode\u003Ersnapshot configtest\u003C\/code\u003E is the thing that saves you.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003E--numeric-ids\u003C\/code\u003E on rsync.\u003C\/strong\u003E Because uid 33 has no name on either machine, you\nwant rsync to preserve the raw numeric IDs rather than trying (and failing) to\nmap them to names. I also remap the \u003Cem\u003Estored\u003C\/em\u003E copy to a real local user so the\nsnapshots are pleasant to browse, but the wire transfer stays numeric.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Ch2\u003EHonest take\u003C\/h2\u003E\n\u003Cp\u003EThe interesting realization was that \"dockerize it\" didn't make the backup harder\nso much as it \u003Cem\u003Erelocated\u003C\/em\u003E the difficulty. The old setup's pain was all in\nfile ownership — groups, setgid, making sure new files inherited the right group.\nDocker took that specific pain away (the container stamps gid 33 on everything,\nconsistently) and replaced it with a smaller, cleaner version of the same\nquestion: \"which group is gid 33 on the host, and is \u003Ccode\u003Esyncusr\u003C\/code\u003E in it?\" The AI\ncollaboration was at its best when it refused to let me hand-wave the scope —\npinning down \"files-only, not a full restore\" early meant I didn't accidentally\ndesign a half-baked database backup I'd have trusted and shouldn't have. The least\nglamorous decisions — pull not push, read-only by group, files-only and \u003Cem\u003Esay so\u003C\/em\u003E\n— are the ones I'll be glad about if I ever actually have to restore.\u003C\/p\u003E\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/3130800505579848634\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/06\/time-travel-for-your-files-snapshot.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/3130800505579848634"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/3130800505579848634"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/06\/time-travel-for-your-files-snapshot.html","title":"Time Travel for Your Files: Snapshot Backups"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEi5Fn0LuCpeWhgdmfs6CHZ8UD2ZGMJXBogXOjmN67zOW0WtAYXB-I6tBCZoa6bvhqYXgk1AhmKL-gEuc4a1-deKF8Xz6YELLk2Ie3dznKFCha0W03erPePnN5rsWPBHb0s5JHwxV89-SsC_vrIXMu3fVohdBF8GdL06CO_dRzbp_tosODm1514Sz5oCZUM\/s72-c\/time_travel.png","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-6084542293849168258"},"published":{"$t":"2026-06-21T07:57:06.560-07:00"},"updated":{"$t":"2026-06-21T08:03:26.320-07:00"},"title":{"type":"text","$t":"Days 11–12 — Product Cards, and a Third Audit That Forced a Rewrite"},"content":{"type":"html","$t":"\u003Cdiv class=\"separator\" style=\"clear: both;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEg4hqKh3EvHM3FHKrP72w6LaRb0AbQ3XGXo1AXxrkbW0iRP9BDUk1APGRUkRmy6FqS1D57kJAC0Nj7kji9B0Qi4FSOVh2u63IE4P1_YASuYKHn6btJ-obInivQmXzjV-VQ5pPshR-vQSI5ez0omZDWyJ49oe1Pgk81zALj7Fh_cOQmporwdfeat0KinF4g\/s1536\/day11-12.png\" style=\"display: block; padding: 1em 0; text-align: center; \"\u003E\u003Cimg alt=\"\" border=\"0\" width=\"320\" data-original-height=\"1024\" data-original-width=\"1536\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEg4hqKh3EvHM3FHKrP72w6LaRb0AbQ3XGXo1AXxrkbW0iRP9BDUk1APGRUkRmy6FqS1D57kJAC0Nj7kji9B0Qi4FSOVh2u63IE4P1_YASuYKHn6btJ-obInivQmXzjV-VQ5pPshR-vQSI5ez0omZDWyJ49oe1Pgk81zALj7Fh_cOQmporwdfeat0KinF4g\/s320\/day11-12.png\"\/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EDate:\u003C\/strong\u003E 2026-05-26\u003Cbr\u003E\u003Cstrong\u003EStatus at end of these two days:\u003C\/strong\u003E All 130 recipe pages show product card grids with real product images instead of plain text affiliate links; Amazon logo badges stripped from 63 recipe intros. Then a third content audit on challah bread surfaced 13 misses with three root causes, and \u003Ccode\u003Epatch-body-images.ts\u003C\/code\u003E was rewritten as a sequential block walker — 130 recipes re-extracted, 0 failed. 73\/73 tests pass throughout.\u003C\/p\u003E\n\u003Chr\u003E\n\u003Cp\u003EEverything here is built with Claude Code, and these two days are a clean demonstration of why the discipline around the model matters more than the model\u0026#39;s raw speed. Claude wrote a Playwright scraper, a 55-product mapping script, two migration patchers, and a from-scratch rewrite of the content-extraction walker — work that would have taken me days alone, compressed into an afternoon each. But none of the failures below were crashes. They were plausible-but-wrong output: a script that reported \u0026quot;0 updated\u0026quot; and was actually correct, a \u003Ccode\u003EPromise.all\u003C\/code\u003E that raced two uploads of the same file, an ASIN quietly mapped to the wrong photo, an extraction that grabbed Amazon links instead of step photos, and — the big one — a regex that had been silently dropping every-other paragraph across all 130 recipes while reporting \u0026quot;130 updated, 0 failed\u0026quot; for three rounds straight. Every one of those would have shipped on a YOLO run, because every one of them produced output that \u003Cem\u003Elooked\u003C\/em\u003E right. What caught them was not a smarter prompt; it was the habit of checking the real thing — the rendered DOM, the Payload database, the live WordPress page side-by-side, the aioseo image sitemap as a ground-truth checklist — instead of trusting the model\u0026#39;s account of what it did. The whole reason the audit even happened is that a human looked at a real page and saw braiding photos that the \u0026quot;successful\u0026quot; scripts had been throwing away. That\u0026#39;s the judgment you bring \u003Cem\u003Earound\u003C\/em\u003E the AI, not instead of it: the model writes the boilerplate fast, and you stay senior enough to notice when \u0026quot;no errors\u0026quot; and \u0026quot;correct\u0026quot; have quietly come apart.\u003C\/p\u003E\n\u003Ch2\u003ETLDR\u003C\/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EBuilt:\u003C\/strong\u003E A new \u0026quot;Shop the Ingredients\u0026quot; product card grid — responsive 2–3 column layout, product photo on a warm background, name, orange \u0026quot;Shop on Amazon\u0026quot; CTA. Replaces the previous plain text affiliate links. 55 unique products mapped, uploaded, and linked across all 130 recipes; Amazon logo badges stripped from 63 intros.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EThen broke open the content again:\u003C\/strong\u003E A side-by-side of the redesigned challah bread page against the live WordPress site revealed 13 missing pieces — three opening paragraphs, a Braiding section, four step photos, two videos, a Variations heading, three gallery captions, an Under Proofing heading, and a final slice image.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EThree root causes, one design flaw:\u003C\/strong\u003E (1) an optional regex group caused every-other paragraph to be silently dropped; (2) step images inside \u003Ccode\u003Ewp:media-text\u003C\/code\u003E figures were never matched by the \u003Ccode\u003Ewp:image\u003C\/code\u003E-only regex; (3) \u003Ccode\u003Ewp:heading\u003C\/code\u003E blocks were never in scope. All three trace back to the same choice — two independent regexes merged after the fact.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EThe fix:\u003C\/strong\u003E Rewrote \u003Ccode\u003EextractIntroWithImages()\u003C\/code\u003E as a single forward-scanning block walker (\u003Ccode\u003EBLOCK_START_RE\u003C\/code\u003E + a \u003Ccode\u003EprocessedEnd\u003C\/code\u003E guard) that handles \u003Ccode\u003Eparagraph\u003C\/code\u003E, \u003Ccode\u003Eimage\u003C\/code\u003E, \u003Ccode\u003Eheading\u003C\/code\u003E, \u003Ccode\u003Equote\u003C\/code\u003E, and the image variant of \u003Ccode\u003Emedia-text\u003C\/code\u003E while letting nested content fall through naturally.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlops:\u003C\/strong\u003E \u003Ccode\u003Estrip-amazon-logo.ts\u003C\/code\u003E \u0026quot;updated 0 recipes\u0026quot; (human confusion — the target was already cleaned on a prior run); a \u003Ccode\u003EPromise.all\u003C\/code\u003E filename race on duplicate product images; a copy-paste ASIN→image mapping error; an early extraction that grabbed Amazon \u003Ccode\u003Ehref\u003C\/code\u003Es instead of \u003Ccode\u003E\u0026lt;img src\u0026gt;\u003C\/code\u003E; a \u003Ccode\u003EtextFormat: 0\u003C\/code\u003E literal type that rejected italic captions (\u003Ccode\u003Eformat: 2\u003C\/code\u003E).\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EWins:\u003C\/strong\u003E Playwright DOM scrape pulled all five live-site product images + URLs in one \u003Ccode\u003Eevaluate()\u003C\/code\u003E call; all 55 product images already existed locally (zero remote fetches); the \u003Ccode\u003Ewpjc_aioseo_posts.sql\u003C\/code\u003E image sitemap gave a ground-truth checklist of the 13 expected images; the \u003Ccode\u003EprocessedEnd\u003C\/code\u003E guard made the nested-block problem trivial.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EResult:\u003C\/strong\u003E Product cards live on every recipe page, and the content extraction rebuilt on authoritative WordPress data — challah bread now 25 paragraphs \/ 12 images, heaviest page (yule log) 90 paragraphs \/ 42 images, all rendering. Clean commits on main.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EKnown gap:\u003C\/strong\u003E Two \u003Ccode\u003E.m4v\u003C\/code\u003E braid-demo videos still don\u0026#39;t render — Payload\u0026#39;s Lexical richText has no native video node. Captions are preserved; videos logged as a future TODO.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Chr\u003E\n\u003Ch2\u003EPart 1 — From \u0026quot;Shop Now\u0026quot; to product cards\u003C\/h2\u003E\n\u003Cp\u003EThe brief was a design task: \u0026quot;there\u0026#39;s a \u0026#39;shop now\u0026#39; button on some pages — I don\u0026#39;t like how it looks, let\u0026#39;s make it a nice card.\u0026quot; Under the hood it was three separate jobs: fix the data (upload product images as Payload media and link them to equipment items), fix the content (strip the Amazon logo badge images that \u003Ccode\u003Epatch-body-images.ts\u003C\/code\u003E had embedded in recipe intros), and fix the UI (redesign the equipment section layout).\u003C\/p\u003E\n\u003Cp\u003EWe started with the dal-coconut-curry page as a live specimen. Playwright scraped the live site in one \u003Ccode\u003Eevaluate()\u003C\/code\u003E call, returning all five product links with their associated images and Amazon URLs:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-javascript\"\u003E{\n  text: \u0026quot;SHOP NOW\u0026quot;,\n  href: \u0026quot;https:\/\/www.amazon.com\/Spicy-World-Masoor...\/dp\/B000K89490...\u0026quot;,\n  imgSrc: \u0026quot;https:\/\/herfoodblog.com\/wp-content\/uploads\/2024\/01\/dal-773x1024.jpg\u0026quot;\n}\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EAll five images already existed in \u003Ccode\u003Eresources\/wp-uploads\/\u003C\/code\u003E from the Day 10 extraction — no network requests needed to populate them.\u003C\/p\u003E\n\u003Cp\u003EThe new UI is a responsive grid — 2-col on mobile, 3-col on desktop. Each card is a white rounded box with a warm \u003Ccode\u003E#F7F3ED\u003C\/code\u003E image area, \u003Ccode\u003Eobject-contain\u003C\/code\u003E layout so different product shapes don\u0026#39;t get cropped weirdly, the product name below, and an orange \u0026quot;Shop on Amazon\u0026quot; button at the bottom. Hover gives a subtle border-colour change and scales the image up 5%.\u003C\/p\u003E\n\u003Cp\u003EAfter confirming it looked good on dal-coconut-curry, we built the 55-ASIN mapping from the WXR XML (a one-shot Node.js script that parsed all \u003Ccode\u003Ewp:media-text\u003C\/code\u003E blocks and extracted ASIN→image URL pairs), put it in \u003Ccode\u003Epatch-equipment-thumbnails.ts\u003C\/code\u003E, and ran it for all 130 recipes. The strip script (\u003Ccode\u003Estrip-amazon-logo.ts\u003C\/code\u003E) fetched all Payload media whose filename contained \u0026quot;available_at_amazon\u0026quot;, then scanned every recipe\u0026#39;s intro richText for upload nodes with those media IDs and removed them. 63 recipes updated.\u003C\/p\u003E\n\u003Ch2\u003EPart 2 — The third audit: 13 misses, three root causes, one rewrite\u003C\/h2\u003E\n\u003Cp\u003EThis one started with a comparison my friend sent: her local redesigned challah bread page next to the live WordPress version. The difference was striking. The live page had braiding instructions with photos, variations with a gallery, an under-proofing section with a diagnostic image. The local page had a few paragraphs and then jumped straight to the recipe card. We counted 13 specific misses and wrote them into \u003Ccode\u003Edocuments\/content-misses.md\u003C\/code\u003E.\u003C\/p\u003E\n\u003Ch3\u003EThe data my friend provided\u003C\/h3\u003E\n\u003Cp\u003EThe previous extraction rounds had used the WXR file and a \u003Ccode\u003Ewp-uploads\/\u003C\/code\u003E dump. This time we needed to understand \u003Cem\u003Ewhy\u003C\/em\u003E the content was missing, not just patch it again. My friend ran three new SQL exports from her WordPress database:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003Ewpjc_posts.sql\u003C\/code\u003E (60MB) — the full posts table, which let us confirm the \u003Ccode\u003Epost_content\u003C\/code\u003E for challah bread (post ID 9744) matched what we expected from the WXR\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Ewpjc_postmeta.sql\u003C\/code\u003E — post metadata, used to cross-reference block structure\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Ewpjc_aioseo_posts.sql\u003C\/code\u003E — the All-in-One SEO image sitemap, which listed all 13 image filenames for post 9744: \u003Ccode\u003EIMG_8470\u003C\/code\u003E, \u003Ccode\u003EIMG_1112\u003C\/code\u003E, \u003Ccode\u003EIMG_1115\u003C\/code\u003E, \u003Ccode\u003EIMG_1650\u003C\/code\u003E, \u003Ccode\u003EIMG_1656\u003C\/code\u003E, \u003Ccode\u003EIMG_1660\u003C\/code\u003E, \u003Ccode\u003EIMG_1101\u003C\/code\u003E, \u003Ccode\u003EIMG_9608\u003C\/code\u003E, \u003Ccode\u003EIMG_0378\u003C\/code\u003E, \u003Ccode\u003EIMG_0383\u003C\/code\u003E, \u003Ccode\u003EIMG_8452\u003C\/code\u003E, \u003Ccode\u003EIMG_5737\u003C\/code\u003E, \u003Ccode\u003EIMG_1668\u003C\/code\u003E. This was the authoritative list of what \u003Cem\u003Eshould\u003C\/em\u003E be on the page — a ground truth to check against.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003EWith the image sitemap as a checklist, we could see immediately that 5 of the 13 images were absent from the local page. That told us where to look.\u003C\/p\u003E\n\u003Ch3\u003ERoot cause 1: the alternating paragraph skip\u003C\/h3\u003E\n\u003Cp\u003EThe old \u003Ccode\u003Epatch-body-images.ts\u003C\/code\u003E ran two global regexes — one for paragraphs, one for images — collected all matches into an array, sorted by \u003Ccode\u003Ematch.index\u003C\/code\u003E, and built the Lexical doc in that order. The paragraph regex looked like this (simplified):\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003E\/\u0026lt;!-- wp:paragraph((?:[\\s\\S]*?)?)?--\u0026gt;...([\\s\\S]*?)\u0026lt;!-- \\\/wp:paragraph --\u0026gt;\/g\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe \u003Ccode\u003E(?:[\\s\\S]*?)?\u003C\/code\u003E optional attribute group is the problem. In a long document with many consecutive paragraph blocks, the engine can \u0026quot;skip\u0026quot; over a block\u0026#39;s opening comment and latch onto the next one — because the optional group allows zero-length matches. The result: every-other paragraph silently dropped.\u003C\/p\u003E\n\u003Cp\u003EQuerying the Payload database confirmed it. The challah intro had 18 nodes:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003Enode 1:  \u0026quot;This festive bread…\u0026quot;             ← PRESENT\nnode 2:  \u0026quot;Ritually acceptable challah…\u0026quot;    ← MISS\nnode 3:  \u0026quot;This recipe is due…\u0026quot;             ← PRESENT\nnode 4:  \u0026quot;Braiding\u0026quot;                        ← MISS (heading — not even in scope)\nnode 5:  \u0026quot;Braiding can be tricky…\u0026quot;         ← MISS\n...\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe alternating pattern was unmistakable — a systematic regression affecting roughly half the paragraphs on every recipe with more than two or three consecutive paragraph blocks.\u003C\/p\u003E\n\u003Ch3\u003ERoot cause 2: step images inside \u003Ccode\u003Ewp:media-text\u003C\/code\u003E\u003C\/h3\u003E\n\u003Cp\u003EWordPress\u0026#39;s media-text block renders an image alongside a paragraph:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-html\"\u003E\u0026lt;!-- wp:media-text {\u0026quot;mediaId\u0026quot;:9799,\u0026quot;mediaType\u0026quot;:\u0026quot;image\u0026quot;} --\u0026gt;\n\u0026lt;div class=\u0026quot;wp-block-media-text\u0026quot;\u0026gt;\n  \u0026lt;figure class=\u0026quot;wp-block-media-text__media\u0026quot;\u0026gt;\n    \u0026lt;img src=\u0026quot;https:\/\/herfoodblog.com\/wp-content\/uploads\/2023\/08\/IMG_0378-665x997.jpg\u0026quot; ...\/\u0026gt;\n  \u0026lt;\/figure\u0026gt;\n  \u0026lt;div class=\u0026quot;wp-block-media-text__content\u0026quot;\u0026gt;\n    \u0026lt;!-- wp:paragraph --\u0026gt;\u0026lt;p\u0026gt;Mix everything using a paddle attachment...\u0026lt;\/p\u0026gt;\u0026lt;!-- \/wp:paragraph --\u0026gt;\n  \u0026lt;\/div\u0026gt;\n\u0026lt;\/div\u0026gt;\n\u0026lt;!-- \/wp:media-text --\u0026gt;\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe old image regex matched \u003Ccode\u003E\u0026lt;!-- wp:image --\u0026gt;\u003C\/code\u003E block comments only. The \u003Ccode\u003Ewp:media-text\u003C\/code\u003E block embeds the \u003Ccode\u003E\u0026lt;img\u0026gt;\u003C\/code\u003E directly inside the figure div. The image was right there in the HTML, completely invisible to the regex. Four step photos in challah were lost this way.\u003C\/p\u003E\n\u003Ch3\u003ERoot cause 3: headings never included\u003C\/h3\u003E\n\u003Cp\u003EThe old script recognised exactly two block types: \u003Ccode\u003Ewp:paragraph\u003C\/code\u003E and \u003Ccode\u003Ewp:image\u003C\/code\u003E. \u003Ccode\u003Ewp:heading\u003C\/code\u003E was never in the alternation, so \u0026quot;Braiding\u0026quot;, \u0026quot;Variations\u0026quot;, and \u0026quot;Under proofing\u0026quot; were silently skipped on every recipe.\u003C\/p\u003E\n\u003Ch3\u003EThe fix: a sequential block walker\u003C\/h3\u003E\n\u003Cp\u003EAll three root causes trace back to the same design choice — two separate regexes applied independently, then merged. The fix is to process the document in a single forward pass:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Econst BLOCK_START_RE = \/\u0026lt;!-- wp:(paragraph|image|heading|media-text|quote)((?:[^-]|-(?!-\u0026gt;))*)--\u0026gt;\/g\nlet processedEnd = 0\n\nwhile ((match = BLOCK_START_RE.exec(body)) !== null) {\n  \/\/ Skip if this opening falls inside an already-processed block\n  if (match.index \u0026lt; processedEnd) {\n    BLOCK_START_RE.lastIndex = processedEnd\n    continue\n  }\n  \/\/ Find the matching close tag, process the block, advance processedEnd\n  ...\n}\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe \u003Ccode\u003EprocessedEnd\u003C\/code\u003E guard is what makes it work. When we process a \u003Ccode\u003Ewp:media-text\u003C\/code\u003E block, \u003Ccode\u003EprocessedEnd\u003C\/code\u003E advances past the entire block including its nested \u003Ccode\u003Ewp:paragraph\u003C\/code\u003E and \u003Ccode\u003Ewp:image\u003C\/code\u003E content, so the next iteration skips anything before it — the nested paragraph doesn\u0026#39;t get double-counted as a standalone paragraph. Gallery images work the same way in reverse: because \u003Ccode\u003Ewp:gallery\u003C\/code\u003E isn\u0026#39;t in the alternation, the walker \u003Cem\u003Edoesn\u0026#39;t\u003C\/em\u003E consume it as a single block, and its inner \u003Ccode\u003Ewp:image\u003C\/code\u003E blocks are found naturally on later iterations.\u003C\/p\u003E\n\u003Cp\u003EFor \u003Ccode\u003Ewp:heading\u003C\/code\u003E we added it to the alternation and emitted a Lexical \u003Ccode\u003Eheading\u003C\/code\u003E node:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Econst tagMatch = blockInner.match(\/\u0026lt;h([2-4])[^\u0026gt;]*\u0026gt;([\\s\\S]*?)\u0026lt;\\\/h\\1\u0026gt;\/)\nconst tag = `h${tagMatch[1]}` as \u0026#39;h2\u0026#39; | \u0026#39;h3\u0026#39; | \u0026#39;h4\u0026#39;\nchildren.push({ type: \u0026#39;heading\u0026#39;, version: 1, direction: \u0026#39;ltr\u0026#39;, format: \u0026#39;\u0026#39;, indent: 0,\n  tag, children: [{ type: \u0026#39;text\u0026#39;, ..., text: headingText }] })\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EFor \u003Ccode\u003Ewp:media-text\u003C\/code\u003E we pull the image out of the \u003Ccode\u003E\u0026lt;figure class=\u0026quot;wp-block-media-text__media\u0026quot;\u0026gt;\u003C\/code\u003E div and upload it, then extract the caption paragraph from \u003Ccode\u003E\u0026lt;div class=\u0026quot;wp-block-media-text__content\u0026quot;\u0026gt;\u003C\/code\u003E.\u003C\/p\u003E\n\u003Chr\u003E\n\u003Ch2\u003EAI flops\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u003Ccode\u003Estrip-amazon-logo.ts\u003C\/code\u003E updating 0 recipes.\u003C\/strong\u003E First run (with only 5 ASINs) correctly stripped Amazon logos from dal-coconut-curry. The second run for all recipes returned \u0026quot;Updated 0 recipe(s)\u0026quot;. I spent several minutes writing a more targeted version before realising: the logos were already gone from dal-coconut-curry (from run 1) and no other recipe had been processed yet. The script was finding the right media IDs but they genuinely weren\u0026#39;t in any intro. The script was correct — the confusion was mine.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u003Ccode\u003EPromise.all\u003C\/code\u003E filename conflict.\u003C\/strong\u003E The original equipment-thumbnail script processed all items for a recipe in parallel. Some recipes have the same product twice under different names (e.g. two piping-bag items both mapping to \u003Ccode\u003Epipingbags-1024x1014.jpg\u003C\/code\u003E). Both parallel \u003Ccode\u003EuploadImage()\u003C\/code\u003E calls would query for an existing record (find nothing), both upload, and the second would hit a \u003Ccode\u003EValidationError: filename\u003C\/code\u003E unique constraint:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EValidationError: The following field is invalid: filename\n  at handleUpsertError (\/node_modules\/@payloadcms\/drizzle\/...)\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EFixed by switching to a \u003Ccode\u003Efor...of\u003C\/code\u003E loop so uploads happen one at a time; the second call\u0026#39;s dedup check then finds the first\u0026#39;s record and reuses the ID.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EWrong ASIN for a product.\u003C\/strong\u003E While building the 55-entry ASIN map from the WXR output, \u003Ccode\u003EB00004S7V8\u003C\/code\u003E got assigned \u003Ccode\u003Ecooling-rack-730x1024.jpg\u003C\/code\u003E (the same image as \u003Ccode\u003EB0041HO4NW\u003C\/code\u003E). The WXR clearly showed \u003Ccode\u003EB00004S7V8: IMG_7951-683x1024.jpg\u003C\/code\u003E — a copy-paste error during manual grouping. Caught during the full-run log review.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EEarly extraction grabbed the wrong images.\u003C\/strong\u003E Before fully understanding the \u003Ccode\u003Ewp:media-text\u003C\/code\u003E structure, an early attempt found images by \u003Ccode\u003Ehref\u003C\/code\u003E attributes in the block markup rather than \u003Ccode\u003Esrc\u003C\/code\u003E attributes in the \u003Ccode\u003E\u0026lt;img\u0026gt;\u003C\/code\u003E tags. This produced media IDs for Amazon product links, not step photos. Running in \u003Ccode\u003E--dry-run\u003C\/code\u003E caught it before any bad data reached the database.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe alternating skip survived two prior rounds.\u003C\/strong\u003E Rounds 2 and 3 ran the script against all 130 recipes and reported \u0026quot;130 updated, 0 failed\u0026quot;. Everything looked fine. The bug was present from the start but the output was never compared against a known-correct reference. We only caught it because of the side-by-side with the live site. Script success messages don\u0026#39;t mean content correctness — you need a human to look at a real page.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ETypeScript flop.\u003C\/strong\u003E Italic captions render with \u003Ccode\u003EtextFormat: 2\u003C\/code\u003E (italic bitmask), but \u003Ccode\u003ELexicalParagraphNode\u003C\/code\u003E had \u003Ccode\u003EtextFormat: 0\u003C\/code\u003E as a literal type:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EType \u0026#39;2\u0026#39; is not assignable to type \u0026#39;0\u0026#39;.\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe fix was one character: change \u003Ccode\u003EtextFormat: 0\u003C\/code\u003E to \u003Ccode\u003EtextFormat: number\u003C\/code\u003E. Use \u003Ccode\u003Enumber\u003C\/code\u003E for bitmask fields whenever the value can vary — don\u0026#39;t infer the literal from the common case.\u003C\/p\u003E\n\u003Chr\u003E\n\u003Ch2\u003EAI wins\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EPlaywright DOM scrape as a data-extraction tool.\u003C\/strong\u003E Instead of parsing the WXR for product images (which stores different filenames per size), I scraped the rendered live page and extracted the exact image+URL pairing the template had assembled:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-javascript\"\u003Econst amazonLinks = document.querySelectorAll(\u0026#39;a[href*=\u0026quot;amazon.com\u0026quot;]\u0026#39;);\namazonLinks.forEach(link =\u0026gt; {\n  const block = link.closest(\u0026#39;.wp-block-media-text\u0026#39;);\n  const img = block?.querySelector(\u0026#39;img\u0026#39;);\n  imgs.push({ href: link.href, imgSrc: img?.src });\n});\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EOne \u003Ccode\u003Eevaluate()\u003C\/code\u003E call returned all five products in structured form — no HTML parsing, no regex, no rate limiting.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAll 55 files already local.\u003C\/strong\u003E After extracting the full ASIN list from the WXR XML, we checked each image against \u003Ccode\u003Eresources\/wp-uploads\/\u003C\/code\u003E. Zero missing. The full run uploaded them to Payload media without touching the live site once.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe aioseo image sitemap as ground truth.\u003C\/strong\u003E Using \u003Ccode\u003Ewpjc_aioseo_posts.sql\u003C\/code\u003E as a checklist of expected images was a clean debugging move. Before writing any code we knew exactly which 13 images should appear on the challah page, and after the fix we could verify each one was newly uploaded or reused. No guessing.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u003Ccode\u003EprocessedEnd\u003C\/code\u003E makes the nesting problem trivial.\u003C\/strong\u003E The hardest part of a sequential walker for nested Gutenberg markup is avoiding double-counts. The \u003Ccode\u003EprocessedEnd\u003C\/code\u003E guard solves it in four lines — when the walker moves past a \u003Ccode\u003Ewp:media-text\u003C\/code\u003E block, all content inside it becomes unreachable to subsequent matches. Simpler and more correct than any regex-alternation trick.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe 280px gap was not our bug.\u003C\/strong\u003E While on the product-card work, we noticed a 280px blank space between the intro and Ingredients on every recipe page. It could have sent the investigation toward leftover upload nodes or empty Lexical containers. Checking the actual DOM:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-javascript\"\u003Econst ins = document.querySelector(\u0026#39;.adsbygoogle\u0026#39;);\nins.getBoundingClientRect() \/\/ → { height: 280 }\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003E\u003Ccode\u003ENEXT_PUBLIC_ADSENSE_CLIENT_ID\u003C\/code\u003E is set in \u003Ccode\u003E.env.local\u003C\/code\u003E, so the AdSlot renders and AdSense\u0026#39;s script initialises the \u003Ccode\u003E\u0026lt;ins\u0026gt;\u003C\/code\u003E to 280px even without ad content in dev. Not a bug we introduced — the gap would be filled by a real ad in production.\u003C\/p\u003E\n\u003Chr\u003E\n\u003Ch2\u003EKnown gap: videos\u003C\/h2\u003E\n\u003Cp\u003EThe challah page originally had two embedded \u003Ccode\u003E.m4v\u003C\/code\u003E video demonstrations — a crown braid and a 5-strand braid — using \u003Ccode\u003Ewp:media-text\u003C\/code\u003E with \u003Ccode\u003E\u0026quot;mediaType\u0026quot;:\u0026quot;video\u0026quot;\u003C\/code\u003E. The walker detects this case and skips the video itself, preserving only the text caption from the content div.\u003C\/p\u003E\n\u003Cp\u003EPayload\u0026#39;s Lexical richText has no native video node type. Options for later:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA custom Lexical node with a \u003Ccode\u003E\u0026lt;video\u0026gt;\u003C\/code\u003E element\u003C\/li\u003E\n\u003Cli\u003EEmbed as an \u003Ccode\u003E\u0026lt;iframe\u0026gt;\u003C\/code\u003E via a custom node\u003C\/li\u003E\n\u003Cli\u003ELink out to the video files from a text paragraph\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003ENot in scope for this round. Captions are preserved and the rest of the content is correct.\u003C\/p\u003E\n\u003Chr\u003E\n\u003Ch2\u003EEnd of these two days\u003C\/h2\u003E\n\u003Cp\u003EDone: product card grid on all recipe pages with images uploaded and linked, Amazon logo badges stripped from 63 intros; \u003Ccode\u003Epatch-body-images.ts\u003C\/code\u003E rewritten with a sequential block walker, tested on challah bread, all 13 content misses resolved, full run for all 130 recipes (130 updated, 0 failed). Yule log spot-checked: 90 paragraphs and 42 step images rendering. Commits \u003Ccode\u003E96b090b\u003C\/code\u003E and \u003Ccode\u003E4c9fd54\u003C\/code\u003E on main. 73\/73 tests passing.\u003C\/p\u003E\n\u003Cp\u003ENot done: production deploy (still queued from Day 10); video rendering (future TODO — Payload Lexical has no native video node).\u003C\/p\u003E\n\u003Cp\u003EThe honest take: the product-card work was clean, and its debugging flops were human errors — forgetting a script had already run, a copy-paste slip in a data map. The audit was sharper. The previous scripts reported success because they ran without errors, but \u0026quot;no errors\u0026quot; and \u0026quot;correct content\u0026quot; are different things. Having the raw WordPress database to query against let us confirm the alternating-miss pattern definitively before touching code. That\u0026#39;s the right way to debug: find the evidence, understand the pattern, then fix. And the pattern that keeps holding up is \u0026quot;AI writes the boilerplate, human checks the data\u0026quot; — the WXR parser, the Playwright scrape, the block walker were all fast to write; catching the silently-wrong output is the judgment you bring around the model, not instead of it.\u003C\/p\u003E\n\u003Chr\u003E\n\u003Cp\u003E\u003Cem\u003EThis is part of an ongoing series documenting the rebuild of a friend\u0026#39;s food blog from WordPress to a custom Next.js stack, built with AI assistance.\u003C\/em\u003E\u003C\/p\u003E\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/6084542293849168258\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/06\/days-1112-product-cards-and-third-audit.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/6084542293849168258"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/6084542293849168258"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/06\/days-1112-product-cards-and-third-audit.html","title":"Days 11–12 — Product Cards, and a Third Audit That Forced a Rewrite"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEg4hqKh3EvHM3FHKrP72w6LaRb0AbQ3XGXo1AXxrkbW0iRP9BDUk1APGRUkRmy6FqS1D57kJAC0Nj7kji9B0Qi4FSOVh2u63IE4P1_YASuYKHn6btJ-obInivQmXzjV-VQ5pPshR-vQSI5ez0omZDWyJ49oe1Pgk81zALj7Fh_cOQmporwdfeat0KinF4g\/s72-c\/day11-12.png","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-7553893430460059632"},"published":{"$t":"2026-06-14T09:22:01.831-07:00"},"updated":{"$t":"2026-06-14T09:26:43.714-07:00"},"title":{"type":"text","$t":"Day 07–10 — Finishing Plan 4, a Dead API, the Lighthouse Reality Check, and a Brutal New-Machine Debug"},"content":{"type":"html","$t":"\u003Cdiv class=\"separator\" style=\"clear: both;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhTKxfXGN6mOSGyh5y6r4pV34z3BDsdvjYTHICoa8K28O8Zs-qPmoASZokHs3C4IIG7eoOHBnyoW-2D9sJxuZiYPPQbR0YvPunHBeEXrU4vsuNbwmzzEgMFMzk88fVhYySEo31V9-zZ_0dLHE1mviomZRR0THNhEpytjbwQpHbvQBW7mOcdiQNyJcCqCfw\/s1536\/day07-10.png\" style=\"display: block; padding: 1em 0; text-align: center; \"\u003E\u003Cimg alt=\"\" border=\"0\" width=\"320\" data-original-height=\"1024\" data-original-width=\"1536\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhTKxfXGN6mOSGyh5y6r4pV34z3BDsdvjYTHICoa8K28O8Zs-qPmoASZokHs3C4IIG7eoOHBnyoW-2D9sJxuZiYPPQbR0YvPunHBeEXrU4vsuNbwmzzEgMFMzk88fVhYySEo31V9-zZ_0dLHE1mviomZRR0THNhEpytjbwQpHbvQBW7mOcdiQNyJcCqCfw\/s320\/day07-10.png\"\/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EDate:\u003C\/strong\u003E 2026-05-24 – 2026-05-25\u003Cbr\u003E\u003Cstrong\u003EStatus at end of day:\u003C\/strong\u003E All four plans complete. SEO, structured data, and the missing pages shipped; AdSense wired with \u003Ccode\u003ElazyOnload\u003C\/code\u003E; Lighthouse audited and its limits documented; a machine migration survived two silently-failing bugs; and the content migration was rebuilt from authoritative WordPress data across three rounds — 130 recipes patched, inline images and galleries included. 73 tests passing, 0 lint errors, 0 TypeScript errors. Production deploy is the only thing left.\u003C\/p\u003E\n\u003Chr\u003E\n\u003Cp\u003EEverything here is built with Claude Code, and I\u0026#39;ll say the quiet part out loud: the productivity is genuinely addictive. Across these four days Claude wrote structured-data generators, traced a TagError through three wrong theories, rebuilt a migration pipeline, and audited my own CLAUDE.md against the codebase — work that would have taken me days alone, compressed into hours. But the reason it \u003Cem\u003Eworks\u003C\/em\u003E is the part that doesn\u0026#39;t show up in a demo. I\u0026#39;m not vibe coding: I don\u0026#39;t fire a vague prompt, accept the first plausible-looking output, and ship because the page rendered. I\u0026#39;m vibe \u003Cem\u003Eengineering\u003C\/em\u003E — using the planning tools and the implementation tools deliberately. Plans written to disk before any code. Subagent-per-task with a spec review and a code-quality review on top. Verification passes that produce zero commits but catch real gaps. Audits that compare what the code \u003Cem\u003Esays\u003C\/em\u003E against what it \u003Cem\u003Edoes\u003C\/em\u003E. The thing I keep proving to myself over these days is that being a senior engineer is what unlocks the full benefit of the model, not a reason to skip it: the failures below are never crashes, they\u0026#39;re plausible-but-wrong output — a 1×1 pixel \u0026quot;image,\u0026quot; a migration that silently discards \u003Ccode\u003Epost.content\u003C\/code\u003E, a push() I was sure fired twice — and catching that class of failure is exactly the judgment you bring \u003Cem\u003Earound\u003C\/em\u003E the AI, not instead of it. Going YOLO would have shipped every one of these. Engineering the collaboration caught them.\u003C\/p\u003E\n\u003Ch2\u003ETLDR\u003C\/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EDay 07 (Plan 4, partial):\u003C\/strong\u003E Recipe JSON-LD, Open Graph + Twitter metadata for all 11 pages, sitemap.xml, robots.txt, custom 404 + error pages, loading skeletons — on a clean worktree branch. Two-stage review caught a relative JSON-LD image URL (Google rejects it), a nullable \u003Ccode\u003Equantity\u003C\/code\u003E that would print \u0026quot;undefined salt\u0026quot;, a 1×1-pixel OG image, and six pages hardcoding the site URL.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EDay 08 (planning pivot):\u003C\/strong\u003E No code. The plan assumed the Instagram Basic Display API — Meta killed it in December 2024. Skipped Instagram entirely, skipped YouTube by choice to keep scope tight, and documented exactly which AdSense credentials were still needed.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EDay 09 (Plan 4 complete):\u003C\/strong\u003E Merged the SEO branch, wired AdSense via \u003Ccode\u003Enext\/script\u003C\/code\u003E \u003Ccode\u003Estrategy=\u0026quot;lazyOnload\u0026quot;\u003C\/code\u003E, ran Lighthouse. First run scored Performance 41; fixed the favicon 404, an \u003Ccode\u003Earia-hidden-focus\u003C\/code\u003E trap, a WCAG label mismatch, and a sticky-footer CLS bug. Performance settled at 65–74 (AdSense is an inherent drag), Accessibility 96 ✅, SEO 100 ✅. A CLAUDE.md audit caught two real bugs that had been marked \u0026quot;fix in Plan 4\u0026quot; and never fixed.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EDay 10 (new machine + content rebuild):\u003C\/strong\u003E An AdSense TagError fixed on the \u003Cem\u003Efourth\u003C\/em\u003E attempt — three internally-consistent theories about React Strict Mode, all wrong, broken only by instrumentation. A Postgres \u0026quot;cannot connect\u0026quot; error that was a Plan 4 production hardening (removed db port) silently breaking local dev. Then a side-by-side against the live site exposed a migration that discarded \u003Ccode\u003Epost.content\u003C\/code\u003E and \u003Ccode\u003Epost.date\u003C\/code\u003E — fixed across three rounds, ending with a full authoritative extraction (fresh WXR, zip-recipes SQL dump, full media library) and a nasty regex-alternation bug where a paragraph block ate the adjacent image block.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EThe throughline:\u003C\/strong\u003E every flop across these days was a silent one — plausible output with a piece quietly missing or wrong. None threw. All were caught by looking at the real thing (the rendered DOM, the database, the live site) instead of trusting the model\u0026#39;s account of it.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Chr\u003E\n\u003Ch1\u003EDay 07 — Plan 4 (Partial): SEO, Structured Data, and Missing Pages\u003C\/h1\u003E\n\u003Cp\u003E\u003Cstrong\u003EStatus:\u003C\/strong\u003E Plan 4 tasks 6–11 complete on branch \u003Ccode\u003Eworktree-plan4-seo-production\u003C\/code\u003E. Tasks 1–5 (Instagram, YouTube, AdSense) deferred pending API credentials. 73 tests still passing.\u003C\/p\u003E\n\u003Ch2\u003EWhat we did today\u003C\/h2\u003E\n\u003Cp\u003EPlan 4 is the production-readiness pass: integrations, SEO, Lighthouse. We split it into two halves — the credential-free tasks (6–11) and the third-party integrations (1–5, needing Instagram\/YouTube\/AdSense API keys). Today was the credential-free half.\u003C\/p\u003E\n\u003Cp\u003ESix tasks executed via subagent-driven development: fresh implementer subagent per task, spec compliance review, then code quality review. Two tasks needed fix loops.\u003C\/p\u003E\n\u003Ch2\u003ETask 6: Recipe JSON-LD structured data\u003C\/h2\u003E\n\u003Cp\u003EAdded \u003Ccode\u003E\u0026lt;script type=\u0026quot;application\/ld+json\u0026quot;\u0026gt;\u003C\/code\u003E as the first child of \u003Ccode\u003E\u0026lt;article\u0026gt;\u003C\/code\u003E in the Single Recipe page with full Schema.org \u003Ccode\u003ERecipe\u003C\/code\u003E type: name, description, image (absolute URL), author (Person), datePublished, prepTime\/cookTime\/totalTime in ISO 8601 \u003Ccode\u003EPT{n}M\u003C\/code\u003E format, recipeYield, recipeCategory, recipeIngredient (array of strings), recipeInstructions (HowToStep array with position + text).\u003C\/p\u003E\n\u003Cp\u003ETwo correctness issues caught by code quality review:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cp\u003E\u003Ccode\u003Eimage.url\u003C\/code\u003E is a relative path in local dev (\u003Ccode\u003E\/api\/media\/file\/...\u003C\/code\u003E). Schema.org requires absolute URLs. Fixed with: \u003Ccode\u003Eimage.url.startsWith(\u0026#39;http\u0026#39;) ? image.url : \\\u003C\/code\u003E${SITE_URL}${image.url}``.\u003C\/p\u003E\n\u003C\/li\u003E\n\u003Cli\u003E\u003Cp\u003EThe \u003Ccode\u003Equantity\u003C\/code\u003E field on ingredients is not \u003Ccode\u003Erequired: true\u003C\/code\u003E in the Payload collection — it\u0026#39;s nullable. The initial implementation would have produced \u003Ccode\u003E\u0026quot;undefined salt to taste\u0026quot;\u003C\/code\u003E for unquantified ingredients. Fixed with \u003Ccode\u003Efilter(Boolean).join(\u0026#39; \u0026#39;).trim()\u003C\/code\u003E after a nullable-typed cast.\u003C\/p\u003E\n\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cp\u003EAlso extracted named types (\u003Ccode\u003EIngredientItem\u003C\/code\u003E, \u003Ccode\u003EStepItem\u003C\/code\u003E) to replace repeated inline casts in the map callbacks.\u003C\/p\u003E\n\u003Ch2\u003ETask 7: Open Graph + Twitter Card metadata\u003C\/h2\u003E\n\u003Cp\u003EExtended metadata on all 8 frontend pages. Single Recipe gets dynamic OG with the recipe\u0026#39;s featured image (absolute URL, same guard as JSON-LD). All other pages get a shared default OG image.\u003C\/p\u003E\n\u003Cp\u003ETwo issues caught by code quality review:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cp\u003EThe generated \u003Ccode\u003Eog-default.jpg\u003C\/code\u003E was 1×1 pixel (332 bytes). Social scrapers (Facebook, Twitter, Slack) require a minimum image size; 1200×630 is the standard recommendation. Regenerated using the \u003Ccode\u003Esharp\u003C\/code\u003E package (already a project dependency) from an SVG source with the site\u0026#39;s parchment background, burnt-orange title, and grey tagline. Final file: ~27KB.\u003C\/p\u003E\n\u003C\/li\u003E\n\u003Cli\u003E\u003Cp\u003EAll 6 static page \u003Ccode\u003Emetadata\u003C\/code\u003E exports hardcoded \u003Ccode\u003Ehttps:\/\/herfoodblog.com\u003C\/code\u003E directly. This would produce wrong canonical and OG URLs in staging or preview environments. Fixed by creating \u003Ccode\u003Esrc\/lib\/site.ts\u003C\/code\u003E:\u003C\/p\u003E\n\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-ts\"\u003Eexport const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? \u0026#39;https:\/\/herfoodblog.com\u0026#39;\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EAll pages now import and use \u003Ccode\u003ESITE_URL\u003C\/code\u003E. The two dynamic pages that already used \u003Ccode\u003Econst siteUrl = process.env...\u003C\/code\u003E were also updated to use the shared constant — removing the duplicate definitions.\u003C\/p\u003E\n\u003Ch2\u003ETasks 8–9: sitemap.ts and robots.ts\u003C\/h2\u003E\n\u003Cp\u003EStraight implementations using Next.js 15\u0026#39;s built-in \u003Ccode\u003EMetadataRoute.Sitemap\u003C\/code\u003E and \u003Ccode\u003EMetadataRoute.Robots\u003C\/code\u003E. Both use \u003Ccode\u003ESITE_URL\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cp\u003ESitemap includes 7 static routes with explicit priorities and change frequencies, plus one entry per published recipe from \u003Ccode\u003EgetAllRecipeSlugs()\u003C\/code\u003E. Robots disallows \u003Ccode\u003E\/admin\/\u003C\/code\u003E and \u003Ccode\u003E\/api\/\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cp\u003EBoth passed spec and quality review on first pass.\u003C\/p\u003E\n\u003Ch2\u003ETask 10: Custom 404 and error pages\u003C\/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Enot-found.tsx\u003C\/code\u003E — 404 page with \u0026quot;Looks like this recipe got lost in the oven\u0026quot; message (baking-appropriate) and a Link back home. Design tokens throughout: \u003Ccode\u003Efont-display\u003C\/code\u003E for the \u0026quot;404\u0026quot;, \u003Ccode\u003Etext-accent\u003C\/code\u003E for the number, \u003Ccode\u003Ebg-background\u003C\/code\u003E for the page.\u003C\/p\u003E\n\u003Cp\u003E\u003Ccode\u003Eerror.tsx\u003C\/code\u003E — global error boundary with \u003Ccode\u003E\u0026#39;use client\u0026#39;\u003C\/code\u003E and a \u0026quot;Try again\u0026quot; button calling \u003Ccode\u003Ereset()\u003C\/code\u003E. The \u003Ccode\u003Eerror\u003C\/code\u003E prop is typed as required by Next.js but aliased to \u003Ccode\u003E_error\u003C\/code\u003E to satisfy the no-unused-vars lint rule without suppressing the rule globally.\u003C\/p\u003E\n\u003Cp\u003EBoth passed review on first pass.\u003C\/p\u003E\n\u003Ch2\u003ETask 11: Loading skeletons\u003C\/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ESkeletonCard\u003C\/code\u003E component with \u003Ccode\u003Eanimate-pulse\u003C\/code\u003E, matching the recipe card aspect ratio and text line placeholder shapes. Six cards in a 1\/2\/3 column responsive grid — matches the real recipe feed layout.\u003C\/p\u003E\n\u003Cp\u003E\u003Ccode\u003Esrc\/app\/(frontend)\/recipes\/loading.tsx\u003C\/code\u003E re-exports from \u003Ccode\u003E..\/loading\u003C\/code\u003E — DRY, no duplication.\u003C\/p\u003E\n\u003Cp\u003EPassed review on first pass.\u003C\/p\u003E\n\u003Ch2\u003EAI flops\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EJSON-LD relative image URL.\u003C\/strong\u003E The initial implementation passed \u003Ccode\u003Eimage.url\u003C\/code\u003E directly to the JSON-LD \u003Ccode\u003Eimage\u003C\/code\u003E field. In local dev, Payload serves media as relative paths (\u003Ccode\u003E\/api\/media\/file\/...\u003C\/code\u003E). Schema.org and Google\u0026#39;s Rich Results validator require absolute URLs. The implementer didn\u0026#39;t check the runtime value — the spec said \u0026quot;use \u003Ccode\u003Eimage.url\u003C\/code\u003E\u0026quot; and it did. Code quality review caught it. Fix: wrap in an absolute-URL guard using \u003Ccode\u003ESITE_URL\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ENullable ingredient quantity.\u003C\/strong\u003E The Payload collection has \u003Ccode\u003Equantity\u003C\/code\u003E as a non-required text field, meaning it can be \u003Ccode\u003Enull\u003C\/code\u003E or \u003Ccode\u003Eundefined\u003C\/code\u003E. The initial implementation cast it as \u003Ccode\u003Estring\u003C\/code\u003E and used it directly in a template literal — producing \u003Ccode\u003E\u0026quot;undefined salt to taste\u0026quot;\u003C\/code\u003E in the structured data for any recipe with an unquantified ingredient. This is a silent data corruption: TypeScript wouldn\u0026#39;t catch it because the cast suppressed the type check, and the JSON would look valid but fail Google\u0026#39;s recipe parser. Code quality review caught it.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E1×1 OG image.\u003C\/strong\u003E Technically the plan said to \u0026quot;Place a default OG image\u0026quot; — the implementer placed one. It just happened to be a 332-byte JPEG of a single pixel. Both spec review (which checks for file existence) and code quality review (which checks for fitness for purpose) were needed to catch this — the spec reviewer passed it, the quality reviewer flagged it. The fix used \u003Ccode\u003Esharp\u003C\/code\u003E, which was already in the project\u0026#39;s dependencies.\u003C\/p\u003E\n\u003Ch2\u003EAI wins\u003C\/h2\u003E\n\u003Cp\u003EThe two-stage review caught all three issues before any bad code landed on a commit. The JSON-LD relative URL and nullable quantity bugs would have silently shipped to production and only surfaced during Google Search Console review weeks later. The 1×1 OG image would have broken every social share preview immediately.\u003C\/p\u003E\n\u003Cp\u003ETasks 8–11 (sitemap, robots, 404, error, loading) all passed both review stages on the first pass — the specs were tight enough and the implementations mechanical enough that there was nothing to catch.\u003C\/p\u003E\n\u003Ch2\u003EHonest take\u003C\/h2\u003E\n\u003Cp\u003EThe review process paid for itself on tasks 6 and 7. The nullable quantity bug in particular was the kind of issue that\u0026#39;s invisible to the author (the spec said \u0026quot;map ingredients to strings\u0026quot;, the code maps ingredients to strings, TypeScript is happy), only visible to someone asking \u0026quot;what happens when a field that looks required isn\u0026#39;t?\u0026quot; That\u0026#39;s exactly what a code quality reviewer should do — probe the assumptions, not just check the surface.\u003C\/p\u003E\n\u003Cp\u003EThe 1×1 OG image is the funniest failure of the day. The implementer was given a script to generate it using \u003Ccode\u003Ecanvas\u003C\/code\u003E, which wasn\u0026#39;t installed. It fell back to the JPEG byte array — which happened to be 1×1. Spec review passed it. Quality review asked \u0026quot;will this actually work?\u0026quot; and the answer was no. The fix took 30 seconds with \u003Ccode\u003Esharp\u003C\/code\u003E. The lesson: verify fitness for purpose, not just presence.\u003C\/p\u003E\n\u003Chr\u003E\n\u003Ch1\u003EDay 08 — Plan 4 Planning: Skipping Instagram and YouTube, AdSense Only\u003C\/h1\u003E\n\u003Cp\u003E\u003Cstrong\u003EStatus:\u003C\/strong\u003E No new code. Resolved credential questions for Plan 4 Tasks 1–4. Tasks 1 and 2 scrapped; only AdSense (Tasks 3–4) remains before Task 12.\u003C\/p\u003E\n\u003Ch2\u003EWhat we did today\u003C\/h2\u003E\n\u003Cp\u003EShort planning session. No code written, no commits to app code. The goal was to figure out what credentials were needed for Plan 4 Tasks 1–4 so they could be gathered and the tasks implemented in the next session.\u003C\/p\u003E\n\u003Cp\u003EWrote \u003Ccode\u003Edocuments\/credential_requirements.md\u003C\/code\u003E explaining how to get each credential — Instagram token, YouTube API key and channel ID, AdSense publisher ID, and three AdSense slot IDs. Walked through the setup process for each service.\u003C\/p\u003E\n\u003Cp\u003EThen tried to actually get the Instagram token. That\u0026#39;s where the plan fell apart.\u003C\/p\u003E\n\u003Ch2\u003EThe Instagram API problem\u003C\/h2\u003E\n\u003Cp\u003EThe plan called for the \u003Cstrong\u003EInstagram Basic Display API\u003C\/strong\u003E — a simple personal API that generates a token tied to your own Instagram account, letting you read your own recent posts. It\u0026#39;s been the standard approach for food bloggers and personal sites wanting to display their Instagram feed for years.\u003C\/p\u003E\n\u003Cp\u003EMeta shut it down in December 2024.\u003C\/p\u003E\n\u003Cp\u003EThe Facebook Developer portal no longer lists Instagram Basic Display as an available product. The replacement is the \u003Cstrong\u003EInstagram Graph API\u003C\/strong\u003E, which requires:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EConverting your Instagram account to a Business or Creator account\u003C\/li\u003E\n\u003Cli\u003ECreating and connecting a Facebook Page\u003C\/li\u003E\n\u003Cli\u003EGoing through a more complex token flow\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003EFor a personal food blog where the goal is \u0026quot;show 9 recent photos in the sidebar,\u0026quot; that\u0026#39;s a disproportionate amount of setup and ongoing maintenance. The decision was easy: skip it. The \u003Ccode\u003EInstagramFeed\u003C\/code\u003E component already returns \u003Ccode\u003Enull\u003C\/code\u003E when the env var is absent — no changes needed, the sidebar just doesn\u0026#39;t have a feed section.\u003C\/p\u003E\n\u003Ch2\u003EYouTube: skipped by choice\u003C\/h2\u003E\n\u003Cp\u003EThe YouTube embed was straightforward to set up (Google Cloud Console, enable YouTube Data API v3, generate an API key). But given that Instagram was already being dropped, the decision was made to skip YouTube too and keep the scope tight. The component returns \u003Ccode\u003Enull\u003C\/code\u003E without credentials. Can be added later with no code changes — just add the env vars.\u003C\/p\u003E\n\u003Ch2\u003EWhat\u0026#39;s still needed: AdSense only\u003C\/h2\u003E\n\u003Cp\u003ETwo credentials remain before Plan 4 can close out:\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ETask 3 — AdSense script:\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ENEXT_PUBLIC_ADSENSE_CLIENT_ID\u003C\/code\u003E — the publisher ID from AdSense Account settings (\u003Ccode\u003Eca-pub-...\u003C\/code\u003E)\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003E\u003Cstrong\u003ETask 4 — AdSense slot IDs:\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ENEXT_PUBLIC_ADSENSE_SLOT_RECIPE_MID\u003C\/code\u003E — Display ad between intro and ingredients\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003ENEXT_PUBLIC_ADSENSE_SLOT_RECIPE_BOTTOM\u003C\/code\u003E — Display ad between steps and comments\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003ENEXT_PUBLIC_ADSENSE_SLOT_MOBILE_FOOTER\u003C\/code\u003E — Sticky footer ad on mobile\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003EAll three ad units should be \u003Cstrong\u003EDisplay ads\u003C\/strong\u003E type (not In-feed, In-article, or Multiplex).\u003C\/p\u003E\n\u003Cp\u003EFull setup guide in \u003Ccode\u003Edocuments\/credential_requirements.md\u003C\/code\u003E.\u003C\/p\u003E\n\u003Ch2\u003EAlso fixed: stale memory from previous session\u003C\/h2\u003E\n\u003Cp\u003EAt the start of this session, cleaned up two stale entries left over from the previous session:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003Ememory\/execution_state.md\u003C\/code\u003E had a line reading \u003Ccode\u003EBranch: worktree-plan3-interactive-features — not yet merged\u003C\/code\u003E even though the branch had been merged to main in the same session. Updated to reflect the merge.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Ememory\/MEMORY.md\u003C\/code\u003E index still described Plan 3 as pending merge. Updated.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003ESmall admin, but worth doing before starting anything new.\u003C\/p\u003E\n\u003Ch2\u003EHonest take\u003C\/h2\u003E\n\u003Cp\u003EThis is what a planning session looks like when the plan was written six months before execution and one of the APIs it relied on was killed in the interim. The Instagram Basic Display API deprecation wasn\u0026#39;t something the plan could have anticipated — it was announced and removed in a short window. The right response was to notice it quickly, document the decision, and move on rather than trying to force the Graph API replacement into a scope that doesn\u0026#39;t need it.\u003C\/p\u003E\n\u003Cp\u003EThe session produced one document and a few memory updates. Not a satisfying output compared to a task-execution day, but it\u0026#39;s the right call to close out bad assumptions cleanly before touching code.\u003C\/p\u003E\n\u003Chr\u003E\n\u003Ch1\u003EDay 09 — Plan 4 Complete: AdSense, SEO, Lighthouse\u003C\/h1\u003E\n\u003Cp\u003E\u003Cstrong\u003EStatus:\u003C\/strong\u003E Plan 4 fully complete. All 4 plans done. SEO branch merged, AdSense wired, Lighthouse audit run. Production deploy is the only thing left.\u003C\/p\u003E\n\u003Ch2\u003EWhat we did today\u003C\/h2\u003E\n\u003Cp\u003EShort, clean session. Three things: merge the SEO branch, wire up AdSense, update all the docs.\u003C\/p\u003E\n\u003Ch3\u003EMerging the SEO branch\u003C\/h3\u003E\n\u003Cp\u003EThe \u003Ccode\u003Eworktree-plan4-seo-production\u003C\/code\u003E branch had been sitting for a session waiting on AdSense credentials. Eight commits, all clean. Fast-forward merge into main, no conflicts. Files added:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003Esrc\/app\/sitemap.ts\u003C\/code\u003E — Next.js built-in sitemap generation\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Esrc\/app\/robots.ts\u003C\/code\u003E — blocks \u003Ccode\u003E\/admin\u003C\/code\u003E and \u003Ccode\u003E\/api\u003C\/code\u003E from indexers\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Esrc\/app\/not-found.tsx\u003C\/code\u003E — custom 404 page\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Esrc\/app\/error.tsx\u003C\/code\u003E — custom error boundary\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Esrc\/app\/(frontend)\/loading.tsx\u003C\/code\u003E — skeleton for the home\/recipe list pages\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Epublic\/og-default.jpg\u003C\/code\u003E — fallback OG image (1200×630)\u003C\/li\u003E\n\u003Cli\u003EPlus JSON-LD structured data and OG\/Twitter metadata on all 7 page routes\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003EWorktree removed after merge.\u003C\/p\u003E\n\u003Ch3\u003ETask 3: AdSense script\u003C\/h3\u003E\n\u003Cp\u003EOne change to \u003Ccode\u003Esrc\/app\/layout.tsx\u003C\/code\u003E — add the AdSense loader script:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003E\u0026lt;Script\n  src={`https:\/\/pagead2.googlesyndication.com\/pagead\/js\/adsbygoogle.js?client=${process.env.NEXT_PUBLIC_ADSENSE_CLIENT_ID}`}\n  strategy=\u0026quot;lazyOnload\u0026quot;\n  crossOrigin=\u0026quot;anonymous\u0026quot;\n\/\u0026gt;\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003E\u003Ccode\u003Estrategy=\u0026quot;lazyOnload\u0026quot;\u003C\/code\u003E is the important part. It tells Next.js to defer the script until after the page is interactive, so it never blocks first paint or TTI. The AdSense docs want you to put this in \u003Ccode\u003E\u0026lt;head\u0026gt;\u003C\/code\u003E — that\u0026#39;s the fast way to tank your Core Web Vitals score on every page. \u003Ccode\u003ElazyOnload\u003C\/code\u003E means ads may take an extra second to appear, which is the right trade-off for a food blog where recipe content and images need to load first.\u003C\/p\u003E\n\u003Cp\u003EThe component also returns \u003Ccode\u003Enull\u003C\/code\u003E if \u003Ccode\u003ENEXT_PUBLIC_ADSENSE_CLIENT_ID\u003C\/code\u003E isn\u0026#39;t set, so local dev without the env var won\u0026#39;t trigger any ad calls or console errors.\u003C\/p\u003E\n\u003Ch3\u003ETask 4: Ad placement\u003C\/h3\u003E\n\u003Cp\u003ECreated two components and made one change to the recipe page.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u003Ccode\u003EAdSlot.tsx\u003C\/code\u003E\u003C\/strong\u003E — the base component. Client component because it needs \u003Ccode\u003EuseEffect\u003C\/code\u003E to push to \u003Ccode\u003Ewindow.adsbygoogle\u003C\/code\u003E. Renders an \u003Ccode\u003E\u0026lt;ins\u0026gt;\u003C\/code\u003E tag with the right data attributes, wrapped in a div with \u003Ccode\u003Eoverflow-hidden\u003C\/code\u003E and \u003Ccode\u003Earia-hidden=\u0026quot;true\u0026quot;\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u003Ccode\u003EStickyFooterAd.tsx\u003C\/code\u003E\u003C\/strong\u003E — mobile-only, dismissable sticky footer. Two pieces of state: \u003Ccode\u003Edismissed\u003C\/code\u003E and \u003Ccode\u003EisMobile\u003C\/code\u003E. The \u003Ccode\u003EisMobile\u003C\/code\u003E check runs in a \u003Ccode\u003EuseEffect\u003C\/code\u003E since \u003Ccode\u003Ewindow\u003C\/code\u003E isn\u0026#39;t available during SSR. Includes a close button so it\u0026#39;s not permanently glued to the bottom of the screen. Only renders when the client ID env var is set.\u003C\/p\u003E\n\u003Cp\u003EThe dismiss handler is wrapped in \u003Ccode\u003EuseCallback\u003C\/code\u003E before the effect that references it — per the CLAUDE.md gotcha about \u0026quot;cannot access before declaration\u0026quot; linter errors when you declare async handlers after the effects that use them.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EIn-content placements\u003C\/strong\u003E in \u003Ccode\u003Esrc\/app\/(frontend)\/recipes\/[slug]\/page.tsx\u003C\/code\u003E:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EHero image\n↓\nTitle + meta\n↓\nCookMode button\n↓\n[AD: RECIPE_MID slot — rectangle]   ← new\n↓\nIngredientChecklist\n↓\nMethod steps\n↓\nEquipment\n↓\n[AD: RECIPE_BOTTOM slot — rectangle]  ← new\n↓\nNewsletter\n↓\nSocialShareBar\n↓\nComments\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe monetisation constraint in CLAUDE.md is explicit: never inside ingredient checklist, method steps, between step numbers, or Cook Mode. Both placements respect that — one before the checklist, one after the steps but before the newsletter signup. No ad ever sits inside a cooking workflow element.\u003C\/p\u003E\n\u003Cp\u003E\u003Ccode\u003EStickyFooterAd\u003C\/code\u003E is added to \u003Ccode\u003Esrc\/app\/(frontend)\/layout.tsx\u003C\/code\u003E so it appears on all frontend pages, not just recipe pages.\u003C\/p\u003E\n\u003Ch2\u003ENo flops (before Lighthouse)\u003C\/h2\u003E\n\u003Cp\u003ENothing broke. \u003Ccode\u003Enpx tsc --noEmit\u003C\/code\u003E came back clean. \u003Ccode\u003Enpm run lint\u003C\/code\u003E produced zero errors (the 20 warnings are all pre-existing \u003Ccode\u003Eany\u003C\/code\u003E types in the migration scripts). All 73 tests pass.\u003C\/p\u003E\n\u003Cp\u003EThe only thing worth noting: the CLAUDE.md \u003Ccode\u003EuseCallback\u003C\/code\u003E gotcha for dismiss handlers was applied proactively in \u003Ccode\u003EStickyFooterAd.tsx\u003C\/code\u003E without hitting the linter error first. That\u0026#39;s the memory system working as intended — a fix documented from a previous session preventing the same mistake in a new one.\u003C\/p\u003E\n\u003Ch2\u003ETask 12: The Lighthouse audit\u003C\/h2\u003E\n\u003Cp\u003EThen the Lighthouse audit happened.\u003C\/p\u003E\n\u003Cp\u003EFirst run, home page:\u003C\/p\u003E\n\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECategory\u003C\/th\u003E\n\u003Cth\u003EScore\u003C\/th\u003E\n\u003Cth\u003ETarget\u003C\/th\u003E\n\u003C\/tr\u003E\n\u003C\/thead\u003E\n\u003Ctbody\u003E\u003Ctr\u003E\n\u003Ctd\u003EPerformance\u003C\/td\u003E\n\u003Ctd\u003E41\u003C\/td\u003E\n\u003Ctd\u003E90\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAccessibility\u003C\/td\u003E\n\u003Ctd\u003E91\u003C\/td\u003E\n\u003Ctd\u003E95\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBest Practices\u003C\/td\u003E\n\u003Ctd\u003E73\u003C\/td\u003E\n\u003Ctd\u003E90\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESEO\u003C\/td\u003E\n\u003Ctd\u003E100 ✅\u003C\/td\u003E\n\u003Ctd\u003E100\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003C\/tbody\u003E\u003C\/table\u003E\n\u003Cp\u003ESEO nailed it first try. Everything else was below target.\u003C\/p\u003E\n\u003Ch3\u003EWhat was broken (fixable)\u003C\/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EFavicon 404.\u003C\/strong\u003E No \u003Ccode\u003Efavicon.ico\u003C\/code\u003E existed, so every page load 404\u0026#39;d for it. Created \u003Ccode\u003Esrc\/app\/icon.svg\u003C\/code\u003E — a brown rounded-rectangle with an \u0026quot;M\u0026quot; in the brand parchment colour. Next.js App Router picks it up automatically and adds the \u003Ccode\u003E\u0026lt;link\u0026gt;\u003C\/code\u003E tag.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u003Ccode\u003Earia-hidden-focus\u003C\/code\u003E in AdSlot.\u003C\/strong\u003E The \u003Ccode\u003EAdSlot\u003C\/code\u003E component had \u003Ccode\u003Earia-hidden=\u0026quot;true\u0026quot;\u003C\/code\u003E on its wrapper div. The idea was to mark ads as decorative content for screen readers. The problem: AdSense injects focusable elements (iframes, links) into that div. When a focusable element lives inside an \u003Ccode\u003Earia-hidden\u003C\/code\u003E container, keyboard users can tab to it but screen readers skip it — a real accessibility trap. Removed \u003Ccode\u003Earia-hidden\u003C\/code\u003E from the wrapper.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u003Ccode\u003Elabel-content-name-mismatch\u003C\/code\u003E in Footer.\u003C\/strong\u003E The social links showed abbreviated text (\u003Ccode\u003EFa\u003C\/code\u003E, \u003Ccode\u003EPi\u003C\/code\u003E, \u003Ccode\u003EIn\u003C\/code\u003E, \u003Ccode\u003EYo\u003C\/code\u003E) but had \u003Ccode\u003Earia-label=\u0026quot;Facebook\u0026quot;\u003C\/code\u003E etc. WCAG 2.5.3 says if an interactive element has visible text, the accessible name must contain it. \u0026quot;Facebook\u0026quot; contains \u0026quot;Fa\u0026quot; as a prefix but Lighthouse still flags it — and rightly so, because a speech recognition user saying \u0026quot;click Facebook\u0026quot; needs the link\u0026#39;s accessible name to match the full word they\u0026#39;re saying. Fixed by expanding to full text labels and removing the redundant \u003Ccode\u003Earia-label\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ECLS from \u003Ccode\u003EStickyFooterAd\u003C\/code\u003E.\u003C\/strong\u003E The sticky footer ad was causing a cumulative layout shift of 0.20 (home) and 0.34 (recipe). The component used \u003Ccode\u003EuseState(false)\u003C\/code\u003E for an \u003Ccode\u003EisMobile\u003C\/code\u003E flag, then set it in a \u003Ccode\u003EuseEffect\u003C\/code\u003E. This meant: SSR renders nothing, hydration runs the effect, isMobile becomes true, and the fixed footer element is inserted into the DOM post-hydration. Even though \u003Ccode\u003Efixed\u003C\/code\u003E elements don\u0026#39;t affect document flow, Lighthouse counts a fixed element \u003Cem\u003Eentering the viewport\u003C\/em\u003E as a layout shift. Refactored \u003Ccode\u003EStickyFooterAd\u003C\/code\u003E to always render using CSS \u003Ccode\u003Emd:hidden\u003C\/code\u003E instead. The element is in the DOM from the first render — no late insertion, no shift.\u003C\/p\u003E\n\u003Ch3\u003EWhat\u0026#39;s still broken (not fixable without removing ads)\u003C\/h3\u003E\n\u003Cp\u003EAfter fixes, second run:\u003C\/p\u003E\n\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECategory\u003C\/th\u003E\n\u003Cth\u003EScore\u003C\/th\u003E\n\u003Cth\u003ETarget\u003C\/th\u003E\n\u003C\/tr\u003E\n\u003C\/thead\u003E\n\u003Ctbody\u003E\u003Ctr\u003E\n\u003Ctd\u003EPerformance\u003C\/td\u003E\n\u003Ctd\u003E65-74\u003C\/td\u003E\n\u003Ctd\u003E90\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAccessibility\u003C\/td\u003E\n\u003Ctd\u003E96 ✅\u003C\/td\u003E\n\u003Ctd\u003E95\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBest Practices\u003C\/td\u003E\n\u003Ctd\u003E77\u003C\/td\u003E\n\u003Ctd\u003E90\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESEO\u003C\/td\u003E\n\u003Ctd\u003E100 ✅\u003C\/td\u003E\n\u003Ctd\u003E100\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003C\/tbody\u003E\u003C\/table\u003E\n\u003Cp\u003EAccessibility crossed target. SEO held. Performance and Best Practices are still short.\u003C\/p\u003E\n\u003Cp\u003EThe performance drag is AdSense. The Google scripts add 460ms of script evaluation time and 127KB of JS that\u0026#39;s flagged as unused. \u003Ccode\u003Estrategy=\u0026quot;lazyOnload\u0026quot;\u003C\/code\u003E defers the load, but once the script runs it runs slowly. LCP on the home page was 5.9s in the Lighthouse simulation (Moto G Power, slow 4G throttling). On a real device with a normal connection it\u0026#39;s faster — but AdSense is an inherent cost.\u003C\/p\u003E\n\u003Cp\u003EBest Practices at 77 is also AdSense. The two failures are third-party cookies (DoubleClick — unavoidable with AdSense) and bf-cache blocked by Payload CMS\u0026#39;s \u003Ccode\u003ECache-Control: no-store\u003C\/code\u003E API responses.\u003C\/p\u003E\n\u003Cp\u003EThe remaining accessibility failure (color-contrast) is a design token issue: \u003Ccode\u003E#C0622A\u003C\/code\u003E (the accent colour) on \u003Ccode\u003E#FDFAF5\u003C\/code\u003E (parchment background) has a 3.98:1 contrast ratio — below the 4.5:1 WCAG AA requirement for small text. It\u0026#39;s close, and at bold weight the requirement drops to 3:1 which it passes. But at regular-weight 14px it fails. Fixing this would require either darkening the brand accent colour or bolding all small accent-colour text. That\u0026#39;s a design decision for another day.\u003C\/p\u003E\n\u003Cp\u003EThe plan targets (Performance ≥ 90, Best Practices ≥ 90) were written before AdSense was confirmed. For an ad-supported food blog, 65-74 performance is typical — most monetised food blogs score in that range. The scores that matter for organic search ranking — LCP, CLS, FID — are all passing in the green on real-device testing.\u003C\/p\u003E\n\u003Ch2\u003EPost-Lighthouse: verification pass catches a missed fix\u003C\/h2\u003E\n\u003Cp\u003EAfter calling the Lighthouse session done, ran a full feature verification pass against the running app — Playwright at 390px and 1440px, hitting every feature marked complete across all four plans.\u003C\/p\u003E\n\u003Cp\u003EOne bug found: the Header social links.\u003C\/p\u003E\n\u003Cp\u003EThe Footer had been fixed during the Lighthouse session. The social links in \u003Ccode\u003EFooter.tsx\u003C\/code\u003E originally showed abbreviated text (\u003Ccode\u003EFa\u003C\/code\u003E, \u003Ccode\u003EPi\u003C\/code\u003E, \u003Ccode\u003EIn\u003C\/code\u003E, \u003Ccode\u003EYo\u003C\/code\u003E) but had \u003Ccode\u003Earia-label=\u0026quot;Facebook\u0026quot;\u003C\/code\u003E etc. That violates WCAG 2.5.3: if an interactive element has visible text, the accessible name must \u003Cem\u003Econtain\u003C\/em\u003E that visible text. A speech recognition user saying \u0026quot;click Facebook\u0026quot; will fail if the accessible name says \u0026quot;Facebook\u0026quot; but the visible text says something different. Fixed in Footer — full text, no separate \u003Ccode\u003Earia-label\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cp\u003EThe Header had the same pattern. The top-of-page social bar in \u003Ccode\u003EHeader.tsx\u003C\/code\u003E showed \u0026quot;FB\u0026quot;, \u0026quot;PIN\u0026quot;, \u0026quot;IG\u0026quot;, \u0026quot;YT\u0026quot; with \u003Ccode\u003Earia-label=\u0026quot;Facebook\u0026quot;\u003C\/code\u003E etc. Same violation, same component type, different file. Wasn\u0026#39;t caught during the Lighthouse fix because the Lighthouse report flagged the Footer links specifically and that\u0026#39;s where attention went.\u003C\/p\u003E\n\u003Cp\u003EThe fix was the same: expand to full text, drop the redundant \u003Ccode\u003Earia-label\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-tsx\"\u003E\/\/ before\n\u0026lt;a href=\u0026quot;...\u0026quot; aria-label=\u0026quot;Facebook\u0026quot; ...\u0026gt;FB\u0026lt;\/a\u0026gt;\n\u0026lt;a href=\u0026quot;...\u0026quot; aria-label=\u0026quot;Pinterest\u0026quot; ...\u0026gt;PIN\u0026lt;\/a\u0026gt;\n\u0026lt;a href=\u0026quot;...\u0026quot; aria-label=\u0026quot;Instagram\u0026quot; ...\u0026gt;IG\u0026lt;\/a\u0026gt;\n\u0026lt;a href=\u0026quot;...\u0026quot; aria-label=\u0026quot;YouTube\u0026quot; ...\u0026gt;YT\u0026lt;\/a\u0026gt;\n\n\/\/ after\n\u0026lt;a href=\u0026quot;https:\/\/facebook.com\/herfoodblog\u0026quot; ...\u0026gt;Facebook\u0026lt;\/a\u0026gt;\n\u0026lt;a href=\u0026quot;https:\/\/pinterest.com\/herfoodblog\u0026quot; ...\u0026gt;Pinterest\u0026lt;\/a\u0026gt;\n\u0026lt;a href=\u0026quot;https:\/\/instagram.com\/herfoodblog\u0026quot; ...\u0026gt;Instagram\u0026lt;\/a\u0026gt;\n\u0026lt;a href=\u0026quot;https:\/\/youtube.com\/@herfoodblog\u0026quot; ...\u0026gt;YouTube\u0026lt;\/a\u0026gt;\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThis is the kind of bug that Lighthouse can miss. Lighthouse runs on one page at a time. It flagged the Footer links on the home page during the audit. It didn\u0026#39;t flag the Header because the audit was focused and the Header\u0026#39;s social bar is small. The Playwright verification pass opened the accessibility snapshot of the live DOM and made the mismatch obvious.\u003C\/p\u003E\n\u003Cp\u003EThe lesson here is straightforward: when you fix a class of bug in one place, grep for the same pattern everywhere else before calling it done. \u0026quot;This type of fix\u0026quot; is a better search term than the specific file.\u003C\/p\u003E\n\u003Ch2\u003ECLAUDE.md audit catches two more misses\u003C\/h2\u003E\n\u003Cp\u003EAfter the verification pass, ran \u003Ccode\u003E\/claude-md-management:claude-md-improver\u003C\/code\u003E against the project CLAUDE.md. Scored 81\/100. Five issues found.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E1. Category page \u003Ccode\u003E\u0026lt;title\u0026gt;\u003C\/code\u003E uses raw slug — never fixed.\u003C\/strong\u003E The CLAUDE.md gotcha said: \u0026quot;Known SEO gap; fix in Plan 4.\u0026quot; Plan 4 is done. The fix never landed.\u003C\/p\u003E\n\u003Cp\u003E\u003Ccode\u003EgenerateMetadata\u003C\/code\u003E in \u003Ccode\u003Esrc\/app\/(frontend)\/recipes\/page.tsx\u003C\/code\u003E:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Econst title = category ? `${category} Recipes` : \u0026#39;All Recipes\u0026#39;\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003E\u003Ccode\u003Ecategory\u003C\/code\u003E here is the raw URL param — the slug, not the display name. So the \u003Ccode\u003E\u0026lt;title\u0026gt;\u003C\/code\u003E for the cheesecakes page reads \u0026quot;cheesecake Recipes\u0026quot; — lowercase slug, no capitalisation, no pluralisation. The \u003Ccode\u003E\u0026lt;h1\u0026gt;\u003C\/code\u003E on the page is fine because it uses the category name from Payload. But the \u003Ccode\u003E\u0026lt;title\u0026gt;\u003C\/code\u003E tag — which is what Google shows in search results — shows the raw slug. Lighthouse\u0026#39;s SEO score of 100 doesn\u0026#39;t catch this because Lighthouse checks for the \u003Cem\u003Epresence\u003C\/em\u003E of a \u003Ccode\u003E\u0026lt;title\u0026gt;\u003C\/code\u003E tag, not whether its content makes sense.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E2. Stale content in CLAUDE.md.\u003C\/strong\u003E Three other issues cleaned up:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u0026quot;Build order\u0026quot; table (Week 1 → Plan 1, etc.) — describes what already happened; removed\u003C\/li\u003E\n\u003Cli\u003EPlan 4 task list — verbose breakdown no longer needed now that everything is done; replaced with a one-liner\u003C\/li\u003E\n\u003Cli\u003E\u0026quot;Pre-plan audit gate\u0026quot; phrasing — still said \u0026quot;Don\u0026#39;t start Plan N+1 with broken tooling\u0026quot; when all plans are complete; rephrased to \u0026quot;before starting any code work session\u0026quot;\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Edocuments\/\u003C\/code\u003E repo layout entry — listed only \u003Ccode\u003E01_claude-requirements.md\u003C\/code\u003E; updated to mention the credential checklist and home server setup guide also there\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003ENone of these were bugs in the product. But stale context in CLAUDE.md is how the next session starts confused — Claude reads it before doing anything, and wrong information has real cost.\u003C\/p\u003E\n\u003Ch2\u003EFixing the remaining issues\u003C\/h2\u003E\n\u003Cp\u003EThe CLAUDE.md audit surfaced two real bugs that should be fixed, not just documented.\u003C\/p\u003E\n\u003Ch3\u003ECategory page titles\u003C\/h3\u003E\n\u003Cp\u003EThe fix: add \u003Ccode\u003EgetCategoryBySlug()\u003C\/code\u003E to \u003Ccode\u003Esrc\/lib\/payload.ts\u003C\/code\u003E, then use it in both \u003Ccode\u003EgenerateMetadata\u003C\/code\u003E and the page component.\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003E\/\/ payload.ts\nexport async function getCategoryBySlug(slug: string) {\n  const payload = await getPayloadClient()\n  const result = await payload.find({\n    collection: \u0026#39;categories\u0026#39;,\n    where: { slug: { equals: slug } },\n    limit: 1,\n    depth: 0,\n  })\n  return result.docs[0] ?? null\n}\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EIn the page component the recipes query and category lookup are parallelised:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Econst [{ docs: recipes, totalPages }, categoryDoc] = await Promise.all([\n  getPublishedRecipes({ page, limit: 12, category }),\n  category ? getCategoryBySlug(category) : Promise.resolve(null),\n])\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EResult confirmed in browser: \u003Ccode\u003E\u0026lt;title\u0026gt;\u003C\/code\u003E is now \u0026quot;Cheesecakes Recipes | My Love of Baking\u0026quot; and the \u003Ccode\u003E\u0026lt;h1\u0026gt;\u003C\/code\u003E reads \u0026quot;Cheesecakes Recipes\u0026quot;. Both use the display name from Payload, not the URL slug.\u003C\/p\u003E\n\u003Ch3\u003EColor contrast\u003C\/h3\u003E\n\u003Cp\u003EThe accent color \u003Ccode\u003E#C0622A\u003C\/code\u003E has a 4.03:1 contrast ratio against the parchment background \u003Ccode\u003E#FDFAF5\u003C\/code\u003E. WCAG AA requires 4.5:1 for normal text. One-line change to \u003Ccode\u003Esrc\/app\/globals.css\u003C\/code\u003E:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-css\"\u003E--color-accent: #B05826;  \/* was #C0622A — darkened for WCAG AA (4.75:1 on parchment) *\/\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe new value is 8-10% darker per channel. In practice the change is barely perceptible — the color still reads as the same warm orange-brown. The contrast ratio goes from 4.03:1 to ~4.75:1, comfortable headroom above the 4.5:1 threshold.\u003C\/p\u003E\n\u003Ch2\u003EFollow-up session: README overhaul\u003C\/h2\u003E\n\u003Cp\u003EShort session, no code. Documentation only.\u003C\/p\u003E\n\u003Cp\u003EThe README was accurate but thin on developer context — fine for someone who already knows the stack, sparse for a new contributor. Two things changed.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ETechnology Stack section.\u003C\/strong\u003E Added a full breakdown of every piece of the stack, grouped by role: core framework (Next.js 15, Payload CMS v3, PostgreSQL, TypeScript), styling (Tailwind CSS v4 — worth calling out that v4 has no \u003Ccode\u003Etailwind.config.js\u003C\/code\u003E, and the four self-hosted Google Fonts), infrastructure (Docker + Compose, Caddy, GitHub Actions, Cloudflare R2), third-party integrations (Kit, Nodemailer, AdSense, Instagram, YouTube API), and testing tooling (Vitest, ESLint). Each entry explains what it does \u003Cem\u003Ein this project\u003C\/em\u003E, not just what the library is. The rendering strategy (SSG vs ISR, which pages use which) went in too because it affects how a developer thinks about data freshness.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDev setup gaps.\u003C\/strong\u003E Several things that would trip up a new contributor: \u003Ccode\u003ENEXT_PUBLIC_SERVER_URL\u003C\/code\u003E missing from the \u003Ccode\u003E.env.local\u003C\/code\u003E example; the Checks section only documenting \u003Ccode\u003Enpm test\u003C\/code\u003E (added \u003Ccode\u003Enpm run lint\u003C\/code\u003E and \u003Ccode\u003Enpx tsc --noEmit\u003C\/code\u003E with a note about the 3 harmless importmap false positives); \u003Ccode\u003Enpm run devsafe\u003C\/code\u003E undocumented; and an environment-variables table separating required-for-dev from optional, with plain-English notes on what degrades gracefully when each optional group is absent.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EPlan status table.\u003C\/strong\u003E Fixed — still showed Plan 3 as \u0026quot;pending merge\u0026quot; and Plan 4 as \u0026quot;Not started\u0026quot;. No AI flops; Claude drafted the Technology Stack tables correctly on the first pass.\u003C\/p\u003E\n\u003Ch2\u003EHonest take\u003C\/h2\u003E\n\u003Cp\u003EThe Lighthouse run was a useful reality check. The plan targets of ≥90 for Performance and Best Practices were optimistic given AdSense. A food blogger\u0026#39;s Lighthouse scores are going to be dragged by ad scripts — that\u0026#39;s the trade-off for the revenue. The right response is to fix what can be fixed, document what can\u0026#39;t, and not pretend the numbers are different than they are.\u003C\/p\u003E\n\u003Cp\u003EThe CLS fix was the most interesting one. The \u003Ccode\u003EStickyFooterAd\u003C\/code\u003E was using a React state variable to detect mobile, which caused the element to pop into existence after hydration. The fix — use CSS \u003Ccode\u003Emd:hidden\u003C\/code\u003E so the element is always in the DOM — is exactly the kind of insight that comes from running the app rather than just writing tests. You can\u0026#39;t catch \u0026quot;element enters DOM post-hydration and causes layout shift\u0026quot; with a unit test. You have to actually run Lighthouse.\u003C\/p\u003E\n\u003Cp\u003EThe end-of-session audit pattern paid off today. The CLAUDE.md improver caught two real bugs — a stale \u0026quot;fix in Plan 4\u0026quot; note that had never been fixed, and a known accessibility failure that had been deferred as \u0026quot;a design decision for another day.\u0026quot; Running the audit before closing the session turned those into actual fixes rather than carry-forward debt. The next session opens with a clean slate — which matters, because Claude Code has no memory between sessions and reads CLAUDE.md and the memory files cold every time.\u003C\/p\u003E\n\u003Chr\u003E\n\u003Ch1\u003EDay 10 — New Machine, Old Bug, Three Wrong Theories\u003C\/h1\u003E\n\u003Cp\u003E\u003Cstrong\u003EStatus:\u003C\/strong\u003E Machine migration guide in README. AdSense TagError fixed on the fourth attempt. Content fixes complete in three rounds — 130 recipes patched, gallery images included, authoritative zip-recipes data applied from SQL dump. All 73 tests passing.\u003C\/p\u003E\n\u003Ch2\u003ENew machine setup questions\u003C\/h2\u003E\n\u003Cp\u003EMoving to a new Mac raised a couple of questions before touching any code.\u003C\/p\u003E\n\u003Cp\u003EFirst: reuse \u003Ccode\u003EPAYLOAD_SECRET\u003C\/code\u003E from the old machine\u0026#39;s \u003Ccode\u003E.env.local\u003C\/code\u003E or generate a new one? Reuse it. It\u0026#39;s a JWT signing key for the Payload admin — it doesn\u0026#39;t protect content data, doesn\u0026#39;t affect password hashes in the database (those are bcrypt), and there\u0026#39;s no security reason to rotate it between personal dev machines. Copy the whole \u003Ccode\u003E.env.local\u003C\/code\u003E and save the trouble of reconstructing the R2, Kit, SMTP, Instagram, and YouTube vars from scratch.\u003C\/p\u003E\n\u003Cp\u003ESecond: how to move the Postgres data. The README had first-time setup instructions but nothing about migrating an existing database. The answer is \u003Ccode\u003Epg_dump\u003C\/code\u003E through the Docker container:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-bash\"\u003E# Old machine\ndocker compose up -d db\ndocker compose exec db pg_dump -U payload herfoodblog_db \u0026gt; mlob-backup.sql\nzip -r mlob-media.zip media\/\n\n# New machine (after cloning and env files)\ndocker compose up -d db\ndocker compose exec -T db psql -U payload herfoodblog_db \u0026lt; mlob-backup.sql\nunzip mlob-media.zip\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThat went into the README as a new \u0026quot;Moving to a new machine\u0026quot; section. The \u003Ccode\u003E-T\u003C\/code\u003E flag on the restore command is the non-obvious one — it disables pseudo-terminal allocation, required when you\u0026#39;re piping stdin from a file rather than a real terminal.\u003C\/p\u003E\n\u003Ch2\u003EThe AdSense TagError\u003C\/h2\u003E\n\u003Cp\u003EOn the new machine, opening \u003Ccode\u003Ehttp:\/\/localhost:3000\u003C\/code\u003E:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EUncaught TagError: adsbygoogle.push() error: \nAll \u0026#39;ins\u0026#39; elements in the DOM with class=adsbygoogle already have ads in them.\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThis had been present since Plan 4. It was just never the most urgent thing.\u003C\/p\u003E\n\u003Ch3\u003ETheory 1: React Strict Mode double-invoke (wrong)\u003C\/h3\u003E\n\u003Cp\u003EThe obvious explanation: React Strict Mode, enabled by default in Next.js dev, runs effects twice. \u003Ccode\u003EAdSlot.tsx\u003C\/code\u003E calls \u003Ccode\u003Epush()\u003C\/code\u003E in a \u003Ccode\u003EuseEffect\u003C\/code\u003E. Two effect runs, two push calls, AdSense throws on the second.\u003C\/p\u003E\n\u003Cp\u003EFix: \u003Ccode\u003EuseRef(false)\u003C\/code\u003E guard so the second effect run exits early:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-tsx\"\u003Econst pushed = useRef(false)\n\nuseEffect(() =\u0026gt; {\n  if (pushed.current) return\n  pushed.current = true\n  ;(window.adsbygoogle = window.adsbygoogle ?? []).push({})\n}, [])\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EReact\u0026#39;s docs say refs persist through Strict Mode\u0026#39;s simulated unmount\/remount. The ref should still be \u003Ccode\u003Etrue\u003C\/code\u003E when the second run fires. Early return. One push. User tested. Error still there.\u003C\/p\u003E\n\u003Ch3\u003ETheory 2: element status check (wrong)\u003C\/h3\u003E\n\u003Cp\u003EMaybe the ref wasn\u0026#39;t persisting. Tried a different guard: check \u003Ccode\u003Edata-adsbygoogle-status\u003C\/code\u003E on the \u003Ccode\u003E\u0026lt;ins\u0026gt;\u003C\/code\u003E element directly. AdSense sets this attribute after processing a slot. If it\u0026#39;s already set, skip:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-tsx\"\u003EuseEffect(() =\u0026gt; {\n  const el = insRef.current\n  if (!el || el.getAttribute(\u0026#39;data-adsbygoogle-status\u0026#39;)) return\n  ;(window.adsbygoogle = window.adsbygoogle ?? []).push({})\n}, [])\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThis fails for a timing reason: AdSense processes push calls asynchronously. By the time Strict Mode\u0026#39;s second effect run fires, the status attribute isn\u0026#39;t set yet — AdSense hasn\u0026#39;t had a chance to process the first push. Both checks pass, push is called twice anyway. User tested. Error still there.\u003C\/p\u003E\n\u003Ch3\u003ETheory 3: setTimeout + cleanup (wrong)\u003C\/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EsetTimeout(0)\u003C\/code\u003E defers to a macrotask. React Strict Mode\u0026#39;s cleanup runs synchronously — before any macrotask fires. So cleanup cancels the first timer, the second effect queues a new one, only that second timer fires. One push:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-tsx\"\u003EuseEffect(() =\u0026gt; {\n  const timer = setTimeout(() =\u0026gt; {\n    if (el.getAttribute(\u0026#39;data-adsbygoogle-status\u0026#39;)) return\n    ;(window.adsbygoogle = window.adsbygoogle ?? []).push({})\n  }, 0)\n  return () =\u0026gt; clearTimeout(timer)\n}, [])\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EUser tested. Error still there.\u003C\/p\u003E\n\u003Cp\u003EThree theories. Three fixes. All wrong. Time to look at what\u0026#39;s actually happening rather than reason about it.\u003C\/p\u003E\n\u003Ch3\u003EAdding instrumentation\u003C\/h3\u003E\n\u003Cp\u003EAdded \u003Ccode\u003Econsole.log\u003C\/code\u003E at every stage: effect mount, timer fire, push call, cleanup. Then used the Playwright MCP browser tool to navigate to \u003Ccode\u003Ehttp:\/\/localhost:3000\u003C\/code\u003E and read the console directly.\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003E[AdSlot] effect mount, slot: 7607471390\n[AdSlot] timer fired, status: null slot: 7607471390\n[AdSlot] calling push()\n[AdSlot] cleanup, slot: 7607471390\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EPush is called exactly once. No second \u003Ccode\u003Eeffect mount\u003C\/code\u003E. No second \u003Ccode\u003Ecalling push()\u003C\/code\u003E. And the TagError was still there. So the premise of all three theories — push() was being called twice — was wrong.\u003C\/p\u003E\n\u003Ch3\u003EThe actual root cause\u003C\/h3\u003E\n\u003Cp\u003EThe Playwright session also caught the 403 from Google\u0026#39;s ad server. The timing in the console: the 403 arrived \u003Cem\u003Ebefore\u003C\/em\u003E the TagError. AdSense had already contacted Google\u0026#39;s servers and attempted to load an ad before our push() call even ran.\u003C\/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003E\u0026lt;ins\u0026gt;\u003C\/code\u003E element was initialized before our push() queued. When AdSense\u0026#39;s script loads, it auto-scans the DOM for all \u003Ccode\u003E\u0026lt;ins class=\u0026quot;adsbygoogle\u0026quot;\u0026gt;\u003C\/code\u003E elements and initializes them directly — no push() required. Our push() then fired, found all elements already done, and AdSense threw the TagError. The root cause was never React Strict Mode.\u003C\/p\u003E\n\u003Ch3\u003EThe actual fix\u003C\/h3\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-tsx\"\u003EuseEffect(() =\u0026gt; {\n  const el = insRef.current\n  if (!el || el.getAttribute(\u0026#39;data-adsbygoogle-status\u0026#39;)) return\n  \/\/ AdSense auto-inits \u0026lt;ins\u0026gt; elements when its script loads on the first page\n  \/\/ visit. Only call push() once AdSense is already running — handles the SPA\n  \/\/ navigation case where a new slot appears after the script has loaded.\n  if (!window.adsbygoogle || window.adsbygoogle.push === Array.prototype.push) return\n  try {\n    ;(window.adsbygoogle = window.adsbygoogle ?? []).push({})\n  } catch { \/* ignore *\/ }\n}, [])\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EBefore AdSense loads, \u003Ccode\u003Ewindow.adsbygoogle\u003C\/code\u003E is the plain array we initialize; its \u003Ccode\u003Epush\u003C\/code\u003E is \u003Ccode\u003EArray.prototype.push\u003C\/code\u003E. After AdSense loads and takes over the object, \u003Ccode\u003Epush\u003C\/code\u003E is AdSense\u0026#39;s own function. That comparison tells you whether AdSense is running. On initial page load AdSense hasn\u0026#39;t loaded when effects fire → skip, and AdSense auto-scans later. On SPA navigation AdSense is already loaded → call push() to initialize the new slot. First attempt threw \u003Ccode\u003ETypeError: Cannot read properties of undefined (reading \u0026#39;push\u0026#39;)\u003C\/code\u003E — \u003Ccode\u003Ewindow.adsbygoogle\u003C\/code\u003E was undefined before the layout\u0026#39;s init line had run. Added the null guard.\u003C\/p\u003E\n\u003Ch2\u003EThe Postgres connection error\u003C\/h2\u003E\n\u003Cp\u003EWith the AdSense issue closed, the next console error was:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EError: cannot connect to Postgres\n    at getPayloadClient\n    at getPublishedRecipes\n    at HomePage\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EFour hypotheses before the actual cause. \u003Cstrong\u003EPassword mismatch\u003C\/strong\u003E — checked both files, passwords matched. \u003Cstrong\u003EMissing database\u003C\/strong\u003E — \u003Ccode\u003Edocker compose exec db psql -U payload -l\u003C\/code\u003E showed \u003Ccode\u003Eherfoodblog_db\u003C\/code\u003E present with all tables; the repeating \u003Ccode\u003EFATAL: database \u0026quot;payload\u0026quot; does not exist\u003C\/code\u003E logs were just the healthcheck\u0026#39;s \u003Ccode\u003Epg_isready\u003C\/code\u003E connecting to the default db. \u003Cstrong\u003EMalformed DATABASE_URI\u003C\/strong\u003E — the URI had the database name. \u003Cstrong\u003EWrong config var\u003C\/strong\u003E — \u003Ccode\u003Epayload.config.ts\u003C\/code\u003E correctly reads \u003Ccode\u003EDATABASE_URI\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cp\u003EThe actual cause: \u003Ccode\u003Edocker-compose.yml\u003C\/code\u003E\u0026#39;s \u003Ccode\u003Edb\u003C\/code\u003E service had no \u003Ccode\u003Eports\u003C\/code\u003E entry at all.\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-yaml\"\u003Edb:\n  image: postgres:16-alpine\n  environment:\n    POSTGRES_DB: herfoodblog_db\n    POSTGRES_USER: payload\n    POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}\n  volumes:\n    - postgres_data:\/var\/lib\/postgresql\/data\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003ENo \u003Ccode\u003Eports: - \u0026quot;5432:5432\u0026quot;\u003C\/code\u003E. The database container was running fine, but port 5432 was only accessible from other containers on the Docker network — not from the host. \u003Ccode\u003Enpm run dev\u003C\/code\u003E runs the Next.js server on the host and connects to \u003Ccode\u003Elocalhost:5432\u003C\/code\u003E. Without the port mapping, that connection goes nowhere.\u003C\/p\u003E\n\u003Cp\u003EThis was a Plan 4 regression. The \u003Ccode\u003Edocker-compose.yml\u003C\/code\u003E was updated in Plan 4 for production deployment, and one of those changes removed the exposed Postgres port as a security improvement. Correct for production — the app container connects to db via the Docker internal network using hostname \u003Ccode\u003Edb\u003C\/code\u003E. Silently wrong for local dev. The fix exposes the port bound to localhost only:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-yaml\"\u003Eports:\n  - \u0026quot;127.0.0.1:5432:5432\u0026quot;\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Ch2\u003EPre-production content validation\u003C\/h2\u003E\n\u003Cp\u003EWith the database restored and the dev server running cleanly, the next question was: does the local site actually match the live WordPress site? Used Playwright to open both side by side — local dev at \u003Ccode\u003E\/recipes\/sri-lankan-style-chicken-red-curry\u003C\/code\u003E and the live site at the same URL. The reaction: \u0026quot;I see a huge miss in the content.\u0026quot; That was accurate.\u003C\/p\u003E\n\u003Cp\u003EThe live WordPress page has intro\/body text (3–4 paragraphs, per-ingredient tips) and interleaved Amazon affiliate \u0026quot;SHOP NOW\u0026quot; cards above the recipe card. The local dev page had: hero image, category badge, title, \u0026quot;00\u0026quot; next to Serves, ingredients, steps, newsletter, comments. No intro. \u0026quot;00\u0026quot; instead of blank time. No affiliate links, no notes, no date, no tags.\u003C\/p\u003E\n\u003Cp\u003EThe migration script was the cause. In \u003Ccode\u003EcreateRecipes()\u003C\/code\u003E:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Econst introText = post.excerpt.trim() || post.title\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003E\u003Ccode\u003Epost.excerpt\u003C\/code\u003E was empty, so intro fell back to the title — and \u003Ccode\u003Erecipe.intro\u003C\/code\u003E wasn\u0026#39;t even rendered in the page template. \u003Ccode\u003Epost.content\u003C\/code\u003E (the full WP body, with intro paragraphs and Amazon blocks) was parsed into the struct and then \u003Cstrong\u003Ediscarded\u003C\/strong\u003E. \u003Ccode\u003Epost.date\u003C\/code\u003E — same, never written, so \u003Ccode\u003EpublishedAt\u003C\/code\u003E was NULL for every recipe. \u003Ccode\u003Eequipment\u003C\/code\u003E was hard-coded to \u003Ccode\u003E[]\u003C\/code\u003E. The \u003Ccode\u003Enotes\u003C\/code\u003E field didn\u0026#39;t exist in the schema. And the \u0026quot;00\u0026quot; was a React \u003Ccode\u003E\u0026amp;\u0026amp;\u003C\/code\u003E gotcha: \u003Ccode\u003E{recipe.prepTime \u0026amp;\u0026amp; \u0026lt;span\u0026gt;...\u0026lt;\/span\u0026gt;}\u003C\/code\u003E renders the number \u003Ccode\u003E0\u003C\/code\u003E as text when prepTime is 0.\u003C\/p\u003E\n\u003Cp\u003EAfter confirming the full list of misses, a fix plan was drafted rather than jumping into code: add a \u003Ccode\u003Enotes\u003C\/code\u003E field, write a \u003Ccode\u003Epatch-recipes.ts\u003C\/code\u003E that \u003Cem\u003Eupdates\u003C\/em\u003E existing records, and rewrite the page template to render intro\/date\/notes\/tags\/author bio\/related recipes.\u003C\/p\u003E\n\u003Ch2\u003EExecuting the content fix plan (round 1)\u003C\/h2\u003E\n\u003Cp\u003EFive steps. Schema (\u003Ccode\u003Enotes\u003C\/code\u003E textarea, regenerate types). Patch script (\u003Ccode\u003Epatch-recipes.ts\u003C\/code\u003E) with three extractors — \u003Ccode\u003EextractIntro\u003C\/code\u003E (paragraph blocks → Lexical with bold), \u003Ccode\u003EextractEquipment\u003C\/code\u003E (Amazon URLs from \u003Ccode\u003Ewp:media-text\u003C\/code\u003E blocks), \u003Ccode\u003EscrapeNotes\u003C\/code\u003E (the zip-recipes \u003Ccode\u003Ezrdn-element_notes\u003C\/code\u003E element from the live HTML). Page template rewrite. Verification.\u003C\/p\u003E\n\u003Cp\u003EFirst dry run showed \u003Ccode\u003Eequipment: 0 item(s)\u003C\/code\u003E for a recipe with four visible Amazon cards. The \u003Ccode\u003Ewp:media-text\u003C\/code\u003E opening-comment regex was \u003Ccode\u003E[^\/]*?\u003C\/code\u003E — it stopped at the first \u003Ccode\u003E\/\u003C\/code\u003E in the JSON attributes embedded in the comment, and Amazon URLs have slashes. Changed to \u003Ccode\u003E[\\s\\S]*?\u003C\/code\u003E. Second dry run found 4 items, 6 intro paragraphs, the published date, and notes. Full run: \u003Ccode\u003E130 updated, 0 skipped, 0 failed\u003C\/code\u003E (~300ms each for the polite live-site fetch, ~7 minutes total).\u003C\/p\u003E\n\u003Cp\u003EPlaywright confirmed on the curry recipe: intro (6 paragraphs, bold tips via RichText), published date \u0026quot;September 23, 2020\u0026quot;, no \u0026quot;00\u0026quot; (prep\/cook are 0, hidden by the \u003Ccode\u003E\u0026gt; 0\u003C\/code\u003E guard), notes, 4 Amazon affiliate links, 6 tag pills, author bio, and a \u0026quot;You might also like\u0026quot; grid of 3 same-category cards.\u003C\/p\u003E\n\u003Ch2\u003ESecond content audit: inline images and serving size (round 2)\u003C\/h2\u003E\n\u003Cp\u003EA second comparison on challah bread revealed two more misses. \u003Cstrong\u003EInline step images\u003C\/strong\u003E — the live site interleaves 8 stage photos between paragraphs; the round-1 script only extracted \u003Ccode\u003Ewp:paragraph\u003C\/code\u003E blocks and silently skipped \u003Ccode\u003Ewp:image\u003C\/code\u003E. \u003Cstrong\u003EServing size string\u003C\/strong\u003E — the card shows a person count (\u0026quot;10\u0026quot;) and a description (\u0026quot;1 slice\u0026quot;); we only stored the number.\u003C\/p\u003E\n\u003Cp\u003EThe fix: a new \u003Ccode\u003Epatch-body-images.ts\u003C\/code\u003E that processes \u003Ccode\u003Epost.content\u003C\/code\u003E matching both block types in document order, downloads each image, uploads to Payload media, and inserts a Lexical upload node at the right position. The Lexical upload node shape required reading Payload\u0026#39;s source directly: \u003Ccode\u003E{ type: \u0026#39;upload\u0026#39;, version: 2, fields: {}, format: \u0026#39;\u0026#39;, relationTo: \u0026#39;media\u0026#39;, value: \u0026lt;numeric-id\u0026gt; }\u003C\/code\u003E. Full run: 280 new images uploaded, 92 reused, 46 serving sizes populated. (The WXR was exported 2026-05-23 — challah has 8 step photos live but only 4 in the export; a structural cutoff.)\u003C\/p\u003E\n\u003Ch2\u003EWordPress data extraction (round 3)\u003C\/h2\u003E\n\u003Cp\u003EAfter the second audit the pattern was clear: scraping rendered HTML from a plugin\u0026#39;s output is inherently fragile, and the WXR has a fixed export date. Chose \u003Cstrong\u003EOption C\u003C\/strong\u003E — full authoritative extraction: fresh WXR, a database dump of the single zip-recipes table (\u003Ccode\u003Ewpjc_amd_zlrecipe_recipes\u003C\/code\u003E — custom \u003Ccode\u003Ewpjc_\u003C\/code\u003E prefix, older \u003Ccode\u003Eamd_zlrecipe\u003C\/code\u003E naming from the plugin\u0026#39;s \u0026quot;ZL Recipes\u0026quot; past), and the entire \u003Ccode\u003Ewp-content\/uploads\/\u003C\/code\u003E media library via SFTP. The scripts then read local files instead of hitting the live site. Guide written to \u003Ccode\u003Edocuments\/extraction-plan.md\u003C\/code\u003E. (One clarification: the WXR contains both \u003Ccode\u003Ewp:post_id\u003C\/code\u003E and the slug, so the scripts join the recipe table\u0026#39;s \u003Ccode\u003Epost_id\u003C\/code\u003E to Payload records with no separate mapping export — that section was renamed \u0026quot;no action needed.\u0026quot;)\u003C\/p\u003E\n\u003Cp\u003EAfter the files arrived:\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u003Ccode\u003Epatch-zrdn.ts\u003C\/code\u003E\u003C\/strong\u003E — reads the SQL dump (45 rows with valid \u003Ccode\u003Epost_id\u003C\/code\u003E), joins via a WXR-built id→slug map. 37 rows had no usable data (predate the plugin install); only 8 recipes had something — 3 \u003Ccode\u003Eserving_size\u003C\/code\u003E, 5 \u003Ccode\u003Enotes\u003C\/code\u003E. Run last (after \u003Ccode\u003Epatch-body-images.ts\u003C\/code\u003E, which always writes \u003Ccode\u003EservingSize\u003C\/code\u003E even null) to lock the authoritative SQL values on top.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u003Ccode\u003Epatch-body-images.ts\u003C\/code\u003E — local files + galleries.\u003C\/strong\u003E \u003Ccode\u003EresolveLocalFile()\u003C\/code\u003E strips the \u003Ccode\u003Ewp-content\/uploads\/\u003C\/code\u003E prefix and reads from \u003Ccode\u003Eresources\/wp-uploads\/\u003C\/code\u003E, falling back to the network only if missing. \u003Ccode\u003EexpandGalleries()\u003C\/code\u003E replaces each \u003Ccode\u003Ewp:gallery\u003C\/code\u003E wrapper with its nested \u003Ccode\u003Ewp:image\u003C\/code\u003E children as bare blocks before the main loop.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe regex alternation bug.\u003C\/strong\u003E A dry run on challah showed only 2 images instead of 5 after gallery expansion. The block regex shared a closing group between types:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003E\/\u0026lt;!-- (wp:paragraph|wp:image)(?:[\\s\\S]*?)? --\u0026gt;([\\s\\S]*?)\u0026lt;!-- \\\/(wp:paragraph|wp:image) --\u0026gt;\/g\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe non-greedy \u003Ccode\u003E[\\s\\S]*?\u003C\/code\u003E extended a paragraph match past \u003Ccode\u003E\u0026lt;!-- \/wp:paragraph --\u0026gt;\u003C\/code\u003E and closed it at the next \u003Ccode\u003E\u0026lt;!-- \/wp:image --\u0026gt;\u003C\/code\u003E — the paragraph block consumed the adjacent image block. The fix: stop sharing a regex. Run a paragraph-only and an image-only regex separately, collect matches with \u003Ccode\u003Em.index\u003C\/code\u003E, sort by document position, iterate in order.\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Econst paraRegex = \/\u0026lt;!-- wp:paragraph(?:[\\s\\S]*?)? --\u0026gt;([\\s\\S]*?)\u0026lt;!-- \\\/wp:paragraph --\u0026gt;\/g\nconst imageRegex = \/\u0026lt;!-- wp:image(?:[\\s\\S]*?)? --\u0026gt;([\\s\\S]*?)\u0026lt;!-- \\\/wp:image --\u0026gt;\/g\n\nconst blocks: Block[] = []\nwhile ((m = paraRegex.exec(body)) !== null) blocks.push({ type: \u0026#39;paragraph\u0026#39;, index: m.index, inner: m[1] })\nwhile ((m = imageRegex.exec(body)) !== null) blocks.push({ type: \u0026#39;image\u0026#39;, index: m.index, inner: m[1] })\nblocks.sort((a, b) =\u0026gt; a.index - b.index)\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EAfter the fix, challah dry run: 13 paragraphs, 5 images. Full run: 130 updated, 0 failed.\u003C\/p\u003E\n\u003Ch2\u003EHonest take\u003C\/h2\u003E\n\u003Cp\u003ETwo bugs, a content audit, and a full rebuild — the session ended up more complete than expected, and every incident had the same shape: a reasonable assumption, never verified. Push is called twice. The port is open. The migration is complete. All three were wrong, and the fix in each case was to check what\u0026#39;s actually there instead of what should be there.\u003C\/p\u003E\n\u003Cp\u003EOn the AdSense TagError: three wrong theories in a row, each internally consistent — React Strict Mode double-invoke is a real problem, the \u003Ccode\u003EuseRef\u003C\/code\u003E guard is its correct fix, the \u003Ccode\u003EsetTimeout\u003C\/code\u003E trick is valid for that exact problem. The hypotheses were reasonable; they were wrong because the assumption underneath all three (push being called twice) was never tested. What broke the loop wasn\u0026#39;t a smarter theory — it was instrumentation. I should have added logging after theory 1 failed, not after theory 3. Measure first, theorize second.\u003C\/p\u003E\n\u003Cp\u003EThe Playwright MCP tool was what made all of this tractable. Without it, debugging would have been \u0026quot;user re-tests, reports result, repeat\u0026quot; — a slow loop with no visibility. With it, the console output was readable directly, including the ordering of the 403 and the TagError, and once we had that ordering the root cause was obvious in 30 seconds. The same discipline — look at the real thing — is what surfaced the migration misses (a side-by-side against the live site) and what kept the three content rounds honest (a \u003Ccode\u003E--slug\u003C\/code\u003E dry run on one known recipe before every full run). Systems that fail silently are the most dangerous, and the only reliable defense is verifying output against reality rather than against the model\u0026#39;s confident account of it.\u003C\/p\u003E\n\u003Chr\u003E\n\u003Cp\u003E\u003Cem\u003EThis is part of an ongoing series documenting the rebuild of a friend\u0026#39;s food blog from WordPress to a custom Next.js stack, built with AI assistance.\u003C\/em\u003E\u003C\/p\u003E\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/7553893430460059632\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/06\/day-0710-finishing-plan-4-dead-api.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/7553893430460059632"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/7553893430460059632"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/06\/day-0710-finishing-plan-4-dead-api.html","title":"Day 07–10 — Finishing Plan 4, a Dead API, the Lighthouse Reality Check, and a Brutal New-Machine Debug"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhTKxfXGN6mOSGyh5y6r4pV34z3BDsdvjYTHICoa8K28O8Zs-qPmoASZokHs3C4IIG7eoOHBnyoW-2D9sJxuZiYPPQbR0YvPunHBeEXrU4vsuNbwmzzEgMFMzk88fVhYySEo31V9-zZ_0dLHE1mviomZRR0THNhEpytjbwQpHbvQBW7mOcdiQNyJcCqCfw\/s72-c\/day07-10.png","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-2367212718861956890"},"published":{"$t":"2026-06-05T17:15:20.496-07:00"},"updated":{"$t":"2026-06-05T17:45:14.084-07:00"},"title":{"type":"text","$t":"Day 04–06 — All 11 Pages, a Verification Pause, and Every Interactive Feature"},"content":{"type":"html","$t":"\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgohXdlQ39mq1bGg5GVNRgQjIqglcwfnhZflEiLyjqFoHJEtZhFfbbN3J8WCigImLimxZUutUvZ2tLKRIL5_BTd3jDA3JaFwhhSdVxaytSqXt2ikiaaOZhLdsOTfLspBi6JBtzu6chNjYtfmjSGrkILafpVRG0KogiBe-oNS4SnEh_h98VJ2NgR5orrSms\/s1536\/day04-06.png\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1024\" data-original-width=\"1536\" height=\"213\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgohXdlQ39mq1bGg5GVNRgQjIqglcwfnhZflEiLyjqFoHJEtZhFfbbN3J8WCigImLimxZUutUvZ2tLKRIL5_BTd3jDA3JaFwhhSdVxaytSqXt2ikiaaOZhLdsOTfLspBi6JBtzu6chNjYtfmjSGrkILafpVRG0KogiBe-oNS4SnEh_h98VJ2NgR5orrSms\/s320\/day04-06.png\" width=\"320\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EDate:\u003C\/strong\u003E 2026-05-23 – 2026-05-24\u003Cbr \/\u003E\u003Cstrong\u003EStatus at end of day:\u003C\/strong\u003E Plans 2 and 3 complete. All 11 public pages built and navigable with real data, then made fully interactive — search, Cook Mode, ingredient checklist, newsletter, contact, comments, bonus gallery — each backed by a custom API route. 73 tests, 0 lint errors, 0 TS errors. Plan 3 merged to main.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Cp\u003EThis whole project is coding with AI — specifically Claude Code. But the point I keep coming back to is \u003Cem\u003Ehow\u003C\/em\u003E I'm using it. I'm deliberately not going yolo: not firing off a vague prompt, accepting whatever comes back, and shipping it because the page rendered. The goal is to build something logical and maintainable — an app another developer (or future me) can actually reason about — with the AI as a collaborator, not an oracle. Plans on disk before code. Verification after every step. Audits that produce no commits but catch real gaps. That's the distinction I care about: not vibe coding, but vibe engineering. The three days below are a fair test of it — 30-plus subagent dispatches, a deliberate no-code verification session sandwiched in the middle, and the cleanup work that happens every single time I let a verification step slide.\u003C\/p\u003E\n\u003Ch2\u003ETLDR\u003C\/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EBuilt (Day 04):\u003C\/strong\u003E All 11 public pages wired to real data — Home, All Recipes, Single Recipe, My Story, Workshops, Contact, Bonus Recipes, Disclosures, plus every layout component. Site fully browsable for the first time against the 130 recipes and 16 categories from the migration. 58 tests passing.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EBuilt (Day 06):\u003C\/strong\u003E All 11 interactive features — mobile menu, search overlay, Cook Mode, ingredient checklist, grid\/list toggle, newsletter form, contact form, comments, bonus gallery — each backed by a custom API route. 73 tests, 0 lint errors.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EDay 05 was deliberately code-free:\u003C\/strong\u003E a verification session to confirm the Plan 2 foundation before building interactivity on top of it. No commits — and it was the right call.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlop:\u003C\/strong\u003E A subagent committed the MyStory global to \u003Ccode\u003Emain\u003C\/code\u003E instead of the worktree branch and reported success. Caught by checking \u003Ccode\u003Egit log\u003C\/code\u003E, recovered via cherry-pick.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlop:\u003C\/strong\u003E Images returned 400 on first page load — the Media collection had no public read access. One-line fix that should have been in Plan 1.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlop:\u003C\/strong\u003E \u003Ccode\u003ESocialShareBar\u003C\/code\u003E was in the plan's file map but no task ever built it. Caught in a post-session file-map audit.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlop:\u003C\/strong\u003E Probed the category filter with the display name (\u003Ccode\u003Echeesecakes\u003C\/code\u003E) instead of the slug (\u003Ccode\u003Echeesecake\u003C\/code\u003E), suspected a bug, and chased a non-existent problem before realising the filter was correct all along.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlop:\u003C\/strong\u003E Subagents skipped \u003Ccode\u003Enpm run lint\u003C\/code\u003E, so four components had ESLint errors that only surfaced at the final Task 11 audit.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlop:\u003C\/strong\u003E Plan 3 was never merged to main and \u003Ccode\u003Enpm run lint\u003C\/code\u003E was scanning worktree files (222 spurious errors) — both caught in a follow-up audit.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Chr \/\u003E\n\u003Ch1\u003EDay 04 — Plan 2: All 11 Pages, Real Data, Working Images\u003C\/h1\u003E\n\u003Cp\u003E\u003Cstrong\u003EStatus:\u003C\/strong\u003E All 11 public pages built and navigable with real data. 49 tests. Images loading. The site is fully browsable for the first time.\u003C\/p\u003E\n\u003Ch2\u003EWhat we built\u003C\/h2\u003E\n\u003Cp\u003EPlan 2 in full: every public-facing page of the site, wired to the 130 recipes and 16 categories sitting in Postgres from the Plan 0 migration. Home, All Recipes, Single Recipe, My Story, Workshops, Contact, Bonus Recipes, Disclosures — plus Header, CategoryNav, Footer, MobileMenu, RecipeCard, Pagination, WorkshopCard, and the Payload data-fetching helpers that glue it all together.\u003C\/p\u003E\n\u003Cp\u003EBefore we could start, CLAUDE.md had flagged two things Plan 1 missed: a \u003Ccode\u003Ebonus-recipes\u003C\/code\u003E Payload collection (the Bonus Recipes gallery page needs somewhere to store the actual recipes) and a \u003Ccode\u003Emy-story\u003C\/code\u003E global (the My Story page needed a CMS source for the bio content). Both prerequisites landed first, with tests.\u003C\/p\u003E\n\u003Cp\u003EThen: 18 Plan 2 tasks. 20 commits by end of day. 0 lint errors.\u003C\/p\u003E\n\u003Ch2\u003EAI flops\u003C\/h2\u003E\n\u003Ch3\u003EFlop #1: Subagent committed to the wrong branch\u003C\/h3\u003E\n\u003Cp\u003EThe \u003Ccode\u003Emy-story\u003C\/code\u003E global was the second prerequisite. I dispatched a subagent to implement it. The subagent reported success:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003ECommit: 00ad2ef — feat: add MyStory global for My Story page content\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EI checked \u003Ccode\u003Egit log\u003C\/code\u003E in the worktree. It wasn't there.\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-bash\"\u003Egit log --oneline -3\n# dbe2471 fix: add category field and required slug to BonusRecipes\n# 4ea79e9 feat: add BonusRecipes collection\n# 5d0552c chore: update CLAUDE.md with pre-Plan 2 audit learnings\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe commit had landed on \u003Ccode\u003Emain\u003C\/code\u003E. The subagent lost working-directory context somewhere and committed to the main repo checkout rather than the worktree branch.\u003C\/p\u003E\n\u003Cp\u003EFixing it:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-bash\"\u003E# Cherry-pick into the worktree branch\ngit cherry-pick 00ad2ef\n\n# Conflict — both branches had modified payload.config.ts\n# (worktree added BonusRecipes, main added MyStory)\n# Resolve: keep both\ngit add src\/payload.config.ts \u0026amp;\u0026amp; git cherry-pick --continue --no-edit\n\n# Undo the commit from main\ngit -C \/path\/to\/main-repo reset --hard HEAD~1\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003ECLAUDE.md already has a warning about this: \"Subagent commit verification — after any subagent reports a commit, verify with \u003Ccode\u003Egit log\u003C\/code\u003E before moving on.\" It happened again anyway. The fix took maybe 10 minutes once I knew what had happened, but it's annoying because you have to know to check.\u003C\/p\u003E\n\u003Ch3\u003EFlop #2: Plan code had a TypeScript error\u003C\/h3\u003E\n\u003Cp\u003EThe All Recipes page has a pagination component that needs to preserve category filter params in its Next\/Previous links. The plan's template:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Econst extraParams = category ? { category } : {}\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003ETypeScript rejected it. \u003Ccode\u003E{}\u003C\/code\u003E doesn't satisfy \u003Ccode\u003ERecord\u0026lt;string, string\u0026gt;\u003C\/code\u003E — the inferred type is \u003Ccode\u003E{ category?: undefined }\u003C\/code\u003E, which doesn't match. The fix is one explicit annotation:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Econst extraParams: Record\u0026lt;string, string\u0026gt; = category ? { category } : {}\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe subagent reported \u003Ccode\u003EDONE_WITH_CONCERNS\u003C\/code\u003E and called it out explicitly:\u003C\/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\"The plan's template used \u003Ccode\u003Econst extraParams = category ? { category } : {}\u003C\/code\u003E which caused a TS2322 that required fixing.\"\u003C\/p\u003E\n\u003C\/blockquote\u003E\n\u003Cp\u003EThat's the right behavior. The problem is that plans get written before collections and types fully exist, so there's always some drift between what the plan specifies and what strict TypeScript actually accepts. This is a pattern, not a one-off.\u003C\/p\u003E\n\u003Ch3\u003EFlop #3: Images returned 400 — the media access control gap\u003C\/h3\u003E\n\u003Cp\u003EWhen I loaded the home page for the first time, recipe cards rendered correctly: titles, category tags, dates, links. Images were broken — not showing placeholder grey boxes, but \u003Ccode\u003EIMG_0918\u003C\/code\u003E, \u003Ccode\u003EIMG_4793\u003C\/code\u003E... the raw alt text. Browser console:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EFailed to load resource: 400 (Bad Request)\nhttp:\/\/localhost:3000\/_next\/image?url=%2Fapi%2Fmedia%2Ffile%2FIMG_8040-800x533.jpg\u0026amp;w=1200\u0026amp;q=75\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe \u003Ccode\u003E\/_next\/image\u003C\/code\u003E optimizer was returning 400. That means it tried to fetch the source image and got something it couldn't use. Testing the media endpoint directly:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-bash\"\u003Ecurl -s -o \/dev\/null -w \"%{http_code}\" \"http:\/\/localhost:3000\/api\/media\/file\/IMG_8040-800x533.jpg\"\n# 403\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Col start=\"403\"\u003E\n\u003Cli\u003EPayload's default access control for collections is \"authenticated users only\". The Media collection had no explicit \u003Ccode\u003Eaccess\u003C\/code\u003E config, so unauthenticated requests — including the Next.js image optimizer, which runs server-side — got a 403. Which the optimizer treats as \"not a valid image source\", which returns 400 to the browser.\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cp\u003EFix: one line in \u003Ccode\u003Esrc\/collections\/Media.ts\u003C\/code\u003E:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Eaccess: {\n  read: () =\u0026gt; true,\n},\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThis should have been in Plan 1. It's a fundamental requirement for any public site — if you can't read media without logging in, nothing works. The symptom was subtle enough that it didn't surface until we had a real page rendering real images.\u003C\/p\u003E\n\u003Ch3\u003EFlop #4: Worktree missing the media directory\u003C\/h3\u003E\n\u003Cp\u003EAfter fixing access control, images returned 500 instead of 403. Server log:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EERROR: File IMG_8040-800x533.jpg for collection media is missing on the disk.\nExpected path: \/path\/to\/worktree\/media\/IMG_8040-800x533.jpg\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe 130 images from the Plan 0 migration live in the main repo's \u003Ccode\u003Emedia\/\u003C\/code\u003E directory. The git worktree is an isolated directory — it doesn't inherit the main repo's untracked files. So from Payload's perspective, the files just don't exist.\u003C\/p\u003E\n\u003Cp\u003EFix: symlink.\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-bash\"\u003Eln -sf \/Users\/...\/yummy-stuff\/media \\\n       \/Users\/...\/yummy-stuff\/.claude\/worktrees\/plan2-pages-and-navigation\/media\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003ENot a production problem — production uses Cloudflare R2. But it's a worktree development gotcha that's now in CLAUDE.md so we don't debug it again.\u003C\/p\u003E\n\u003Cp\u003EAfter the symlink: the home page loaded with all recipe images. 130 real photos, first load.\u003C\/p\u003E\n\u003Ch3\u003EFlop #5: SocialShareBar — in the file map, missing from every task\u003C\/h3\u003E\n\u003Cp\u003EAfter merging the Plan 2 branch and declaring it done, I ran a cross-check: every file in the plan's file map against what was actually on disk. One file was missing.\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003Esrc\/components\/ui\/SocialShareBar.tsx   ← listed in the plan, never built\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe plan's file map listed it clearly: \u003Cem\u003E\"Static share link row (no JS SDK).\"\u003C\/em\u003E The design had it too — in \u003Ccode\u003Edesign\/editorial.jsx\u003C\/code\u003E, line 462, between the Method section and the Comments section. But none of the 18 tasks ever said \"create SocialShareBar.\" The file map was aspirational; the tasks were what actually ran. Nobody noticed the gap during the session because nothing imported it, nothing broke without it, and the pages all loaded correctly.\u003C\/p\u003E\n\u003Cp\u003EBuilt it after the fact: 6 tests, Facebook\/Twitter\/Pinterest\/LinkedIn URL-based links, wired into the Single Recipe page between Method and Comments. Commit \u003Ccode\u003Ecba7cf9\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cp\u003EWhile fixing the test I found a second problem: a CLAUDE.md gotcha I'd written earlier was wrong. After adding \u003Ccode\u003EenvironmentMatchGlobs\u003C\/code\u003E to vitest, I noted \"no \u003Ccode\u003E\/\/ @vitest-environment jsdom\u003C\/code\u003E docblock needed.\" That turned out to be incorrect in practice — without the docblock, \u003Ccode\u003Edocument is not defined\u003C\/code\u003E. All existing component tests use the docblock. I just hadn't tested the claim. Reverted the gotcha.\u003C\/p\u003E\n\u003Cp\u003ETwo things missed in the same session: one a spec gap (file in the map, no task), one a documentation error (gotcha I hadn't verified). Both caught in the post-session audit.\u003C\/p\u003E\n\u003Ch2\u003EAI wins\u003C\/h2\u003E\n\u003Ch3\u003EThe categories were real, immediately\u003C\/h3\u003E\n\u003Cp\u003ECategoryNav is an async Server Component that calls \u003Ccode\u003EgetCategories()\u003C\/code\u003E on every render. The moment the home page first loaded, all 16 categories from the migration appeared as horizontal scrolling pill buttons: waffles, toppings, sourdough, snacks, scones, pastry, pancakes, Extras, desserts, cookies, cheesecakes, cakes... Zero seed data. Zero mocking. The 130-recipe migration paid off the instant pages existed to surface it.\u003C\/p\u003E\n\u003Cp\u003EThat's the thing about building against real data from the start. You don't find out in week 4 that your category nav doesn't handle 16 items — you find out in week 2, when you can still fix the layout.\u003C\/p\u003E\n\u003Ch3\u003EThe code review caught a missing field it found in the design files\u003C\/h3\u003E\n\u003Cp\u003EThe first version of \u003Ccode\u003EBonusRecipes\u003C\/code\u003E had no \u003Ccode\u003Ecategory\u003C\/code\u003E field. The code quality reviewer subagent — dispatched to read the design files and check implementation quality independently — came back with:\u003C\/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\"The design (\u003Ccode\u003Edesign\/editorial-extras.jsx\u003C\/code\u003E) shows each bonus recipe tile displaying a category label (\u003Ccode\u003Eb.c\u003C\/code\u003E) overlaid on the cover image... The collection has no \u003Ccode\u003Ecategory\u003C\/code\u003E field, so when the Plan 2 bonus-recipes page is built against Payload data there will be nothing to render in those slots.\"\u003C\/p\u003E\n\u003C\/blockquote\u003E\n\u003Cp\u003EIt had read \u003Ccode\u003Edesign\/editorial-extras.jsx\u003C\/code\u003E, found the sample data structure \u003Ccode\u003E{ t: 'Sticky Cardamom Buns', c: 'Yeasted' }\u003C\/code\u003E, traced that to the tile overlay, and connected it to the missing field. I wouldn't have caught that until Plan 3 when we try to render real data in the lightbox. Saved a future debugging session.\u003C\/p\u003E\n\u003Ch3\u003ETDD actually caught the right things\u003C\/h3\u003E\n\u003Cp\u003EThe Pagination component has one tricky requirement: when a category filter is active, the Next\/Previous links need to preserve the \u003Ccode\u003E?category=sourdough\u003C\/code\u003E param alongside the \u003Ccode\u003E?page=2\u003C\/code\u003E they add. The test:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Eit('preserves existing query params', () =\u0026gt; {\n  render(\n    \u0026lt;Pagination currentPage={1} totalPages={3} basePath=\"\/recipes\"\n      extraParams={{ category: 'sourdough' }} \/\u0026gt;,\n  )\n  expect(screen.getByRole('link', { name: \/next\/i })).toHaveAttribute(\n    'href',\n    '\/recipes?category=sourdough\u0026amp;page=2',\n  )\n})\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe \u003Ccode\u003EbuildUrl\u003C\/code\u003E implementation uses \u003Ccode\u003EURLSearchParams\u003C\/code\u003E with spread: \u003Ccode\u003E{ ...extra, page: String(page) }\u003C\/code\u003E. The test confirmed the params merge correctly and in the right order. Implementation passed on first try — the test was specific enough to encode the exact behavior that matters.\u003C\/p\u003E\n\u003Ch2\u003EDay 04 honest take\u003C\/h2\u003E\n\u003Cp\u003EPlan 2 was a coordination problem more than a coding problem. Dispatching 20+ subagents across a single session, tracking which branch each commit landed on, reading design files and cross-referencing them against collection schemas — the actual TypeScript was the easy part.\u003C\/p\u003E\n\u003Cp\u003EThe pattern that worked: dispatch → verify commit in the right branch → read the actual files, not just the report → proceed. Every shortcut on verification created cleanup work. The MyStory wrong-branch incident took 10 minutes to fix and would have been zero if I'd checked immediately after the agent reported done.\u003C\/p\u003E\n\u003Cp\u003EOne thing that held up well: building against real data from day one. When the home page loaded for the first time and 16 real categories appeared in the nav row, that was the payoff for the migration work. You don't design a category pill row for 16 items the same way you design it for 3.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch1\u003EDay 05 — Pre-Plan 3 Audit: All Pages Verified, Three New Gotchas\u003C\/h1\u003E\n\u003Cp\u003E\u003Cstrong\u003EStatus:\u003C\/strong\u003E No new code written. Plan 2 work verified clean against the running app. Plan 3 ready to start.\u003C\/p\u003E\n\u003Cp\u003EThis is the session that produces no commits — and is exactly the kind of step that \"vibe engineering\" means and \"vibe coding\" skips. Plan 2 merged yesterday; today was orienting for Plan 3 and making sure the foundation is solid before building interactive features on top of it.\u003C\/p\u003E\n\u003Cp\u003ESteps:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003ERead the full Plan 3 spec — 11 tasks covering Cook Mode, search overlay, ingredient checklist, grid\/list toggle, newsletter form, contact form, comments, bonus recipes gate, and all the backing API routes.\u003C\/li\u003E\n\u003Cli\u003ERan the verify skill: started the dev server, drove all 11 pages with Playwright, screenshotted each one, probed edge cases.\u003C\/li\u003E\n\u003Cli\u003EUpdated CLAUDE.md with three findings from the verification.\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Ch2\u003EWhat the verification found\u003C\/h2\u003E\n\u003Cp\u003EAll 11 pages render correctly with real data. No regressions from the Plan 2 merge.\u003C\/p\u003E\n\u003Cp\u003ESpecific checks:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EHome, All Recipes, Single Recipe — real recipes, real images, correct layout\u003C\/li\u003E\n\u003Cli\u003EMy Story — CMS placeholder (\"add your story via the Payload admin\") — correct, no content entered yet\u003C\/li\u003E\n\u003Cli\u003EWorkshops — empty state (\"No workshops scheduled yet\") — correct, no workshops seeded\u003C\/li\u003E\n\u003Cli\u003EContact — form fields, topic pills, Send Message button all present\u003C\/li\u003E\n\u003Cli\u003EBonus Recipes — 8 locked tiles with padlock icons — correct (gate logic comes in Plan 3)\u003C\/li\u003E\n\u003Cli\u003EDisclosures — static affiliate + copyright text, renders clean\u003C\/li\u003E\n\u003Cli\u003EMobile @ 390px — hamburger icon, search icon, single-column cards, horizontal pill scroll all working\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003EProbe: bad recipe slug (\u003Ccode\u003E\/recipes\/this-slug-does-not-exist\u003C\/code\u003E) → Next.js default 404, no unhandled errors. \u003Ccode\u003EnotFound()\u003C\/code\u003E fires correctly.\u003C\/p\u003E\n\u003Ch2\u003EFlop: Category filter probe used the name, not the slug\u003C\/h2\u003E\n\u003Cp\u003EI probed the category filter manually by typing \u003Ccode\u003E\/recipes?category=cheesecakes\u003C\/code\u003E into the browser. Got \"No recipes in this category yet.\" Looked like a bug in \u003Ccode\u003EgetPublishedRecipes\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cp\u003EChecked the DB:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003E{ name: \"cheesecakes\", slug: \"cheesecake\" }\n{ name: \"desserts\", slug: \"dessert\" }\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe migration created categories where the slug doesn't match the display name. The filter in \u003Ccode\u003Epayload.ts\u003C\/code\u003E queries \u003Ccode\u003Ecategory.slug\u003C\/code\u003E, which is correct. The CategoryNav pills also link using \u003Ccode\u003Ecat.slug\u003C\/code\u003E, which is correct. Both things work; my probe was using the wrong value.\u003C\/p\u003E\n\u003Cp\u003ERetried with \u003Ccode\u003E\/recipes?category=cheesecake\u003C\/code\u003E → 2 recipes, pill highlighted. Filter works.\u003C\/p\u003E\n\u003Cp\u003EThe real issue this surfaced: the category page \u003Ccode\u003E\u0026lt;title\u0026gt;\u003C\/code\u003E uses the raw slug param, so the browser tab shows \"cheesecake Recipes\" instead of \"Cheesecakes Recipes\". The \u003Ccode\u003E\u0026lt;h1\u0026gt;\u003C\/code\u003E capitalises correctly; \u003Ccode\u003EgenerateMetadata\u003C\/code\u003E doesn't fetch the display name. Minor SEO gap. Noted in CLAUDE.md for Plan 4.\u003C\/p\u003E\n\u003Ch2\u003EAI win: Playwright verification caught a real data mismatch before Plan 3 started\u003C\/h2\u003E\n\u003Cp\u003EThe category slug ≠ name discrepancy in the migrated data wasn't obvious from reading the code — the code is correct. It only surfaced by actually driving the app with real inputs. If that had sat undocumented until Plan 3 when someone builds a category page with a breadcrumb that reads \"cheesecake\", it would have looked like a CMS data quality problem. Now it's documented, root cause known, and the metadata gap is flagged for the right plan.\u003C\/p\u003E\n\u003Ch2\u003ECLAUDE.md updates\u003C\/h2\u003E\n\u003Cp\u003EAdded three gotchas from today's session:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003Etsx\u003C\/code\u003E scripts must live inside the project\u003C\/strong\u003E — scripts in \u003Ccode\u003E\/tmp\/\u003C\/code\u003E fail with \u003Ccode\u003ECannot find module 'payload'\u003C\/code\u003E.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ECategory slug ≠ category name\u003C\/strong\u003E — migrated categories have divergent slugs. Check actual slugs with \u003Ccode\u003Epayload.find({ collection: 'categories' })\u003C\/code\u003E before debugging empty filter results.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ECategory page \u003Ccode\u003E\u0026lt;title\u0026gt;\u003C\/code\u003E uses raw slug\u003C\/strong\u003E — \u003Ccode\u003EgenerateMetadata\u003C\/code\u003E in \u003Ccode\u003Erecipes\/page.tsx\u003C\/code\u003E builds the title from the URL param, not the display name. Known gap; fix in Plan 4.\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Ch2\u003EDay 05 honest take\u003C\/h2\u003E\n\u003Cp\u003EThis kind of session — read the plan, verify the foundation, document what you found — doesn't produce commits. It's easy to skip in favor of just starting. The category slug\/name finding is a good argument for not skipping it: a 20-minute verification surfaced a data quirk that would have cost more time mid-Plan 3 when you're debugging why a category breadcrumb reads wrong.\u003C\/p\u003E\n\u003Cp\u003EThe verify skill's insistence on \"runtime observation, not test runs\" is the right call here. Running \u003Ccode\u003Enpm test\u003C\/code\u003E would have told me the tests pass. It wouldn't have told me that manually typing a category name into a URL returns zero results, or that the page title reads differently from the heading.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch1\u003EDay 06 — Plan 3 Complete: All Interactive Features\u003C\/h1\u003E\n\u003Cp\u003E\u003Cstrong\u003EStatus:\u003C\/strong\u003E Plan 3 done. All 11 tasks complete. 73 tests, 0 lint errors, 0 TS errors.\u003C\/p\u003E\n\u003Ch2\u003EWhat we did\u003C\/h2\u003E\n\u003Cp\u003EStarted Plan 3 — the interactive features pass. Plans 0, 1, and 2 built the foundation (infrastructure, content migration, all 11 pages). Plan 3 is where the site actually becomes usable: search, Cook Mode, ingredient checklist, newsletter signups, contact form, comments, bonus recipe gating. 11 tasks total.\u003C\/p\u003E\n\u003Cp\u003EThe session opened with setup:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003ECreated a new git worktree at \u003Ccode\u003E.claude\/worktrees\/plan3-interactive-features\u003C\/code\u003E\u003C\/li\u003E\n\u003Cli\u003ECopied \u003Ccode\u003E.env\u003C\/code\u003E and \u003Ccode\u003E.env.local\u003C\/code\u003E from the main repo, created the media symlink\u003C\/li\u003E\n\u003Cli\u003ERan the pre-plan audit gate: \u003Ccode\u003Enpm test \u0026amp;\u0026amp; npm run lint \u0026amp;\u0026amp; npx tsc --noEmit\u003C\/code\u003E — all clean (58 tests, 0 errors)\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cp\u003E\u003Cstrong\u003ETask 1 — Install nodemailer.\u003C\/strong\u003E Two commands, committed, done.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ETask 2 — Interactive mobile menu.\u003C\/strong\u003E The \u003Ccode\u003EMobileMenu.tsx\u003C\/code\u003E from Plan 2 was a static shell. Replaced it with a \u003Ccode\u003E'use client'\u003C\/code\u003E component that renders its own hamburger trigger (so the static duplicate came out of \u003Ccode\u003EHeader.tsx\u003C\/code\u003E), manages \u003Ccode\u003EisOpen\u003C\/code\u003E with \u003Ccode\u003EuseState\u003C\/code\u003E, locks body scroll when open, closes on Escape via \u003Ccode\u003EuseEffect\u003C\/code\u003E, and has an inline newsletter form posting to \u003Ccode\u003E\/api\/newsletter\u003C\/code\u003E.\u003C\/p\u003E\n\u003Ch2\u003ETasks 3-4: Search and Cook Mode\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ETask 3 — Search overlay + \u003Ccode\u003E\/api\/search\u003C\/code\u003E.\u003C\/strong\u003E Two pieces: a backend route that queries Payload for matching recipes, and a frontend overlay with debounced live results. TDD first — \u003Ccode\u003Esearch.test.ts\u003C\/code\u003E with 3 cases (short query → early return, missing query → empty, valid query → calls \u003Ccode\u003Epayload.find\u003C\/code\u003E), tests failed, created \u003Ccode\u003Esrc\/app\/api\/search\/route.ts\u003C\/code\u003E using Payload's \u003Ccode\u003Efind\u003C\/code\u003E with a \u003Ccode\u003Elike\u003C\/code\u003E filter on title + tags, tests passed. Then built \u003Ccode\u003ESearchOverlay.tsx\u003C\/code\u003E (300ms debounce, focus-on-open, Escape-to-close, backdrop click) and \u003Ccode\u003ESearchButton.tsx\u003C\/code\u003E, and replaced the static search button in \u003Ccode\u003EHeader.tsx\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cp\u003EOne deviation from the plan spec: the route uses standard \u003Ccode\u003ERequest\u003C\/code\u003E + \u003Ccode\u003Enew URL(request.url)\u003C\/code\u003E instead of \u003Ccode\u003ENextRequest.nextUrl\u003C\/code\u003E. The test harness passes plain \u003Ccode\u003Enew Request(...)\u003C\/code\u003E objects — \u003Ccode\u003ENextRequest.nextUrl\u003C\/code\u003E isn't available without the Next.js runtime. The right call; the route works identically in production.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ETask 4 — Cook Mode.\u003C\/strong\u003E A full-screen step-by-step overlay for following a recipe without touching the phone: Screen Wake Lock API to keep the screen on, keyboard navigation (← →) for desktop, a sticky \"Enter Cook Mode\" CTA pinned to the bottom on mobile, an inline button on desktop, and graceful degradation if Wake Lock isn't supported. Replaced the static dead button in \u003Ccode\u003Erecipes\/[slug]\/page.tsx\u003C\/code\u003E. 61 tests passing after both tasks.\u003C\/p\u003E\n\u003Ch2\u003ETasks 5-6: Ingredient checklist and grid\/list toggle\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ETask 5 — Ingredient checklist.\u003C\/strong\u003E Replaced the static ingredient list with \u003Ccode\u003E\u0026lt;IngredientChecklist\u0026gt;\u003C\/code\u003E: \u003Ccode\u003EuseState\u003C\/code\u003E + \u003Ccode\u003EuseEffect\u003C\/code\u003E to restore checked state from \u003Ccode\u003ElocalStorage\u003C\/code\u003E on mount (key \u003Ccode\u003Echecklist-{slug}\u003C\/code\u003E), each ingredient a \u003Ccode\u003E\u0026lt;button\u0026gt;\u003C\/code\u003E with \u003Ccode\u003Earia-pressed\u003C\/code\u003E (accessible, ≥44px, checkmark + strikethrough when checked), a \"Clear all\" button that appears only when something is checked, and all localStorage access wrapped in try\/catch.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ETask 6 — Grid\/list toggle.\u003C\/strong\u003E TDD: \u003Ccode\u003EGridListToggle.test.tsx\u003C\/code\u003E first (3 tests), failed, built the component, passed. Then \u003Ccode\u003ERecipesClient.tsx\u003C\/code\u003E — a \u003Ccode\u003E'use client'\u003C\/code\u003E wrapper that owns \u003Ccode\u003Eview\u003C\/code\u003E state and renders the toggle plus the grid\/list — and updated \u003Ccode\u003Erecipes\/page.tsx\u003C\/code\u003E to use it. One gotcha: the \u003Ccode\u003E\/\/ @vitest-environment jsdom\u003C\/code\u003E docblock was needed despite \u003Ccode\u003EenvironmentMatchGlobs\u003C\/code\u003E being configured. CLAUDE.md had two contradicting entries about this; in practice it's required. Resolved. 64 tests passing.\u003C\/p\u003E\n\u003Ch2\u003ETasks 7-8: Newsletter form and contact form\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ETask 7 — Newsletter form + \u003Ccode\u003E\/api\/newsletter\u003C\/code\u003E.\u003C\/strong\u003E TDD: \u003Ccode\u003Enewsletter.test.ts\u003C\/code\u003E (missing email → 400, invalid email → 400, valid → calls Kit API), failed, created the route proxying to Kit API v4 (\u003Ccode\u003EX-Kit-Api-Key\u003C\/code\u003E auth), passed. Built \u003Ccode\u003ENewsletterForm.tsx\u003C\/code\u003E with a \u003Ccode\u003Evariant\u003C\/code\u003E prop — \u003Ccode\u003Esidebar\u003C\/code\u003E (white) and \u003Ccode\u003Einline\u003C\/code\u003E (burnt-orange with white text). Added the inline variant to the single recipe page. 67 tests.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ETask 8 — Contact form + \u003Ccode\u003E\/api\/contact\u003C\/code\u003E.\u003C\/strong\u003E TDD: \u003Ccode\u003Econtact.test.ts\u003C\/code\u003E (missing fields → 400, complete → 200), nodemailer mocked, failed, created the route: builds a nodemailer transporter from SMTP env vars, sends an email on every submission, and if \u003Ccode\u003Enewsletter: true\u003C\/code\u003E is included, fires a Kit signup as fire-and-forget (doesn't block or fail the contact response). Built \u003Ccode\u003EContactForm.tsx\u003C\/code\u003E replacing the entirely static form — topic pills switched from radio buttons to \u003Ccode\u003E\u0026lt;button type=\"button\"\u0026gt;\u003C\/code\u003E toggling a \u003Ccode\u003Etopic\u003C\/code\u003E state variable. 69 tests.\u003C\/p\u003E\n\u003Ch2\u003ETasks 9-10: Comments and bonus gallery\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ETask 9 — Comments + \u003Ccode\u003E\/api\/comments\u003C\/code\u003E.\u003C\/strong\u003E TDD: \u003Ccode\u003Ecomments.test.ts\u003C\/code\u003E with 4 cases (GET no recipeId → empty, no Payload call; GET with recipeId → only approved comments for that recipe; POST missing fields → 400; POST complete → creates comment with \u003Ccode\u003Eapproved: false\u003C\/code\u003E, returns 201). Two Payload issues caught during implementation:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cstrong\u003EPayload IDs are numbers, not strings.\u003C\/strong\u003E The plan spec used \u003Ccode\u003Erecipe.id as string\u003C\/code\u003E and a string \u003Ccode\u003Erecipe: recipeId\u003C\/code\u003E. Needed \u003Ccode\u003EString(recipe.id)\u003C\/code\u003E for the page prop and \u003Ccode\u003Erecipe: Number(recipeId)\u003C\/code\u003E in the route so the relationship field gets a number.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003EoverrideAccess: true\u003C\/code\u003E needed in \u003Ccode\u003Epayload.create()\u003C\/code\u003E\u003C\/strong\u003E — public comment submission has to bypass access control.\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cp\u003EAdded \u003Ccode\u003ECommentList.tsx\u003C\/code\u003E (fetches approved comments on mount) and \u003Ccode\u003ECommentForm.tsx\u003C\/code\u003E (submits, shows a moderation notice on success), replacing the \u003Ccode\u003E\"Comments coming soon.\"\u003C\/code\u003E placeholder. 73 tests.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ETask 10 — Bonus gallery + \u003Ccode\u003E\/api\/bonus-unlock\u003C\/code\u003E.\u003C\/strong\u003E The route is simple: validate email, proxy to Kit, return success. The complexity is in \u003Ccode\u003EBonusGallery.tsx\u003C\/code\u003E, which has three \u003Ccode\u003ElocalStorage\u003C\/code\u003E-driven UI states: \u003Cstrong\u003Elocked\u003C\/strong\u003E (padlock on each tile, click triggers the gate), \u003Cstrong\u003Egate modal\u003C\/strong\u003E (email input → \u003Ccode\u003E\/api\/bonus-unlock\u003C\/code\u003E → write \u003Ccode\u003Ebonus-recipes-unlocked=true\u003C\/code\u003E on success), and \u003Cstrong\u003Eunlocked\u003C\/strong\u003E (tiles without locks, click opens a lightbox with title + description). Placeholder data (8 tiles) for now; real content comes from the \u003Ccode\u003Ebonus-recipes\u003C\/code\u003E collection later.\u003C\/p\u003E\n\u003Ch2\u003ETask 11: Full integration verification\u003C\/h2\u003E\n\u003Cp\u003ERan the full audit gate in the worktree:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003Enpm test        → 73 tests, 19 files, all passing\nnpm run lint    → 0 errors (had 4 errors to fix first — see below)\nnpx tsc --noEmit → 0 errors (3 pre-existing importMap noise, expected)\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe lint errors were caught during verification, not during task implementation:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003ECookMode.tsx\u003C\/code\u003E\u003C\/strong\u003E — \u003Ccode\u003EhandleClose\u003C\/code\u003E was declared after a \u003Ccode\u003EuseEffect\u003C\/code\u003E that called it (\u003Ccode\u003Ereact-hooks\/immutability\u003C\/code\u003E, \"Cannot access before declaration\"). Fix: moved it to a \u003Ccode\u003EuseCallback\u003C\/code\u003E above the effect.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003E\u003Ccode\u003EBonusGallery.tsx\u003C\/code\u003E, \u003Ccode\u003EIngredientChecklist.tsx\u003C\/code\u003E, \u003Ccode\u003ESearchOverlay.tsx\u003C\/code\u003E\u003C\/strong\u003E — \u003Ccode\u003Ereact-hooks\/set-state-in-effect\u003C\/code\u003E fires when \u003Ccode\u003EsetState\u003C\/code\u003E is called synchronously inside \u003Ccode\u003EuseEffect\u003C\/code\u003E. All intentional (SSR-safe localStorage init, resetting search state on open). Fix: \u003Ccode\u003E\/\/ eslint-disable-next-line\u003C\/code\u003E on the line immediately before each \u003Ccode\u003EsetState\u003C\/code\u003E call.\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cp\u003ELesson: \u003Ccode\u003Eeslint-disable-next-line\u003C\/code\u003E disables only the very next source line. Placing it before \u003Ccode\u003EuseEffect(...)\u003C\/code\u003E does nothing for setState calls inside the effect body. Classic mistake. All fixed in commit \u003Ccode\u003E91561bd\u003C\/code\u003E.\u003C\/p\u003E\n\u003Ch2\u003EAI flops\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003ELint errors introduced by Plan 3 components.\u003C\/strong\u003E Four components passed TypeScript but failed ESLint. The implementer subagents didn't run \u003Ccode\u003Enpm run lint\u003C\/code\u003E as part of their verification — only \u003Ccode\u003Enpx tsc --noEmit\u003C\/code\u003E. Caught in Task 11. The fix was straightforward; the real gap is the task template. Future task prompts should include \u003Ccode\u003Enpm run lint\u003C\/code\u003E in the verification step.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EPayload ID type mismatch.\u003C\/strong\u003E The plan spec passed relationship field IDs as strings. Payload expects numbers. The agent caught it during the TypeScript check (the right outcome), but the plan spec was wrong from the start.\u003C\/p\u003E\n\u003Ch2\u003EAI wins\u003C\/h2\u003E\n\u003Cp\u003EExecuted 10 implementation tasks back-to-back with zero manual intervention. Each task had a fresh subagent context, so there was no drift or cross-contamination between tasks. The TDD tasks (search, grid\/list, newsletter, contact, comments) all followed the test-first pattern cleanly.\u003C\/p\u003E\n\u003Cp\u003EThe two-stage review (spec compliance + code quality) would have caught the lint errors if I'd enabled lint in the review step. Next plan I'll add \u003Ccode\u003Enpm run lint\u003C\/code\u003E to the implementer verification template.\u003C\/p\u003E\n\u003Ch2\u003EDay 06 honest take\u003C\/h2\u003E\n\u003Cp\u003EPlan 3 is the most satisfying plan so far — it's the point where the site actually does things. Every task was self-contained enough for subagents to handle cleanly. The one discipline failure (not running lint in each task) created cleanup work at the end. The right lesson isn't \"AI writes buggy code\" — it's \"the verification step was underspecified.\" Fix the template, not the trust level.\u003C\/p\u003E\n\u003Ch2\u003EPost-session audit (same day, new context)\u003C\/h2\u003E\n\u003Cp\u003ECame back to start Plan 4 and ran a progress check first — the right call, it turns out.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EPlan 3 was never merged to main.\u003C\/strong\u003E All 11 tasks done, 73 tests passing in the worktree branch, but the branch was still sitting there unmerged. CLAUDE.md said \"pending merge to main\" at the top of the status section. The pre-plan-4 checklist literally says \"Merge Plan 3 branch to main first.\" Neither happened. The subagents did their work correctly — every commit landed on the worktree branch as intended. The failure was mine for not running the merge after verifying Task 11.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u003Ccode\u003Enpm run lint\u003C\/code\u003E was scanning worktree files.\u003C\/strong\u003E The ESLint config had \u003Ccode\u003Edesign\/\u003C\/code\u003E in the ignores array but not \u003Ccode\u003E.claude\/\u003C\/code\u003E. Since git worktrees live at \u003Ccode\u003E.claude\/worktrees\/\u003C\/code\u003E, every \u003Ccode\u003Enpm run lint\u003C\/code\u003E from the main repo scanned all the design canvas JSX in the plan3 worktree — 222 errors unrelated to app code. The \u003Ccode\u003Esrc\/\u003C\/code\u003E-scoped lint was clean (0 errors), but the top-level command would have blocked the Plan 4 audit gate. Nobody noticed because the gate was never run.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EOne leftover unused eslint-disable in \u003Ccode\u003ESearchOverlay.tsx\u003C\/code\u003E.\u003C\/strong\u003E The Task 11 fix added two suppress comments inside the effect; the linter only fires on the first call in a block, so the second was dead weight.\u003C\/p\u003E\n\u003Cp\u003EWhat we fixed:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cstrong\u003EMerged the branch.\u003C\/strong\u003E \u003Ccode\u003Egit merge worktree-plan3-interactive-features --no-ff\u003C\/code\u003E — clean, 29 files, 1457 insertions, all Plan 3 code now on main.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFixed the ESLint ignore list.\u003C\/strong\u003E Added \u003Ccode\u003E.claude\/\u003C\/code\u003E to \u003Ccode\u003Eignores\u003C\/code\u003E in \u003Ccode\u003Eeslint.config.mjs\u003C\/code\u003E.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERemoved the dead disable comment\u003C\/strong\u003E in \u003Ccode\u003ESearchOverlay.tsx\u003C\/code\u003E.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERan the full pre-plan audit gate on main\u003C\/strong\u003E — all three checks passed (73 tests, 0 lint errors, 0 TS errors).\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ECleaned up the worktree.\u003C\/strong\u003E Branch removed, worktree pruned.\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cp\u003E\u003Cstrong\u003EWhy this keeps happening:\u003C\/strong\u003E the \"merge branch\" step is always the last item in the worktree workflow, and it's the one that keeps getting skipped. Subagents execute tasks → tests pass → blog written → session ends. The merge is a meta-step outside the task list, so it doesn't get a green checkbox, and it's easy to skip when the tests already feel like the win. Fix going forward: make the merge a numbered task in the plan itself, not a prerequisite note in a status section nobody reads.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003EThree-day take: vibe engineering, not vibe coding\u003C\/h2\u003E\n\u003Cp\u003EAcross these three days the AI wrote a lot of correct code — but almost every problem that mattered was caught by a verification step, not by the model second-guessing itself. The wrong-branch commit, the 400-ing images, the missing \u003Ccode\u003ESocialShareBar\u003C\/code\u003E, the skipped lint, the unmerged branch: none of them surfaced from a clever prompt. They surfaced from checking \u003Ccode\u003Egit log\u003C\/code\u003E, driving the real app, diffing the file map, and running the audit gate. Day 05 produced zero commits and was one of the most valuable sessions of the three.\u003C\/p\u003E\n\u003Cp\u003EThat's the whole distinction I care about. Going yolo with AI means trusting the report and shipping when the page renders. Vibe engineering means treating the AI as a fast, tireless implementer whose work you still verify like you would any teammate's — plans on disk so decisions survive context resets, real data from day one so layout problems show up early, and a verification habit that assumes the gap is in the process, not the model. The output is the same kind of logical, maintainable app you'd build by hand; the AI just gets you there faster, as long as you keep the discipline that makes it trustworthy.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Cp\u003E\u003Cem\u003EThis is part of an ongoing series documenting the rebuild of a friend's food blog from WordPress to a custom Next.js stack, built with AI assistance.\u003C\/em\u003E\u003C\/p\u003E\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/2367212718861956890\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/06\/day-0406-all-11-pages-verification.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/2367212718861956890"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/2367212718861956890"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/06\/day-0406-all-11-pages-verification.html","title":"Day 04–06 — All 11 Pages, a Verification Pause, and Every Interactive Feature"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgohXdlQ39mq1bGg5GVNRgQjIqglcwfnhZflEiLyjqFoHJEtZhFfbbN3J8WCigImLimxZUutUvZ2tLKRIL5_BTd3jDA3JaFwhhSdVxaytSqXt2ikiaaOZhLdsOTfLspBi6JBtzu6chNjYtfmjSGrkILafpVRG0KogiBe-oNS4SnEh_h98VJ2NgR5orrSms\/s72-c\/day04-06.png","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-8189389435439042777"},"published":{"$t":"2026-06-01T15:10:36.256-07:00"},"updated":{"$t":"2026-06-05T17:46:10.589-07:00"},"title":{"type":"text","$t":"Day 02–03 — Tailwind, Fonts, and the Schema That Lied to the Plan"},"content":{"type":"html","$t":"\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjE9soCj03hb6FbP2ucX7WH16H3PCpMnFL6DFK5Y6loSjNcLUmBdwaczfa9wGc3BF5I6co44NT9lCXISsi54ziVLpj_pnaH1PfuFQfkLnylDAiXrjiiYGQ76bvV467mMZUDymJpj-Mt95y1GS_Bf4x-yhALls9Bw8r7z9uxQekq94TLs7CNdqkdfKmiDz4\/s1536\/day02.png\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1024\" data-original-width=\"1536\" height=\"213\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjE9soCj03hb6FbP2ucX7WH16H3PCpMnFL6DFK5Y6loSjNcLUmBdwaczfa9wGc3BF5I6co44NT9lCXISsi54ziVLpj_pnaH1PfuFQfkLnylDAiXrjiiYGQ76bvV467mMZUDymJpj-Mt95y1GS_Bf4x-yhALls9Bw8r7z9uxQekq94TLs7CNdqkdfKmiDz4\/s320\/day02.png\" width=\"320\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EDate:\u003C\/strong\u003E 2026-05-23\u003Cbr \/\u003E\u003Cstrong\u003EStatus at end of day:\u003C\/strong\u003E Plans 1 and 0 complete. Tailwind v4, Google Fonts, all 5 Payload collections, 2 globals, Docker full stack, CI\/CD, 18\/18 tests passing. 130 recipes, 16 categories, 130 images migrated from WordPress. ESLint fixed, 8 TypeScript errors cleaned up. Ready for Plan 2.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003ETLDR\u003C\/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EBuilt:\u003C\/strong\u003E Tailwind CSS v4 with design tokens, Google Fonts, five Payload collections, two globals, Docker full-stack, GitHub Actions CI\/CD — 18 tests passing. Site is still visually empty but the entire infrastructure is in place.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EBuilt:\u003C\/strong\u003E 130 recipes, 16 categories, and 130 images migrated from WordPress into Payload via a single TypeScript script.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlop:\u003C\/strong\u003E TypeScript errors on Payload relationship fields — \u003Ccode\u003ECollectionSlug\u003C\/code\u003E types don't exist until \u003Ccode\u003Enpm run generate:types\u003C\/code\u003E runs. Fixed with temporary \u003Ccode\u003Eas any\u003C\/code\u003E casts; documented as a known Payload v3 friction point.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlop:\u003C\/strong\u003E A subagent reported a commit that never landed (ghost commit to a detached state). Caught by checking \u003Ccode\u003Egit log\u003C\/code\u003E, recreated manually in two minutes.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlop:\u003C\/strong\u003E \u003Ccode\u003Etsx\u003C\/code\u003E silently ignores \u003Ccode\u003E.env.local\u003C\/code\u003E — Payload init failed with \"missing secret key.\" Fixed with \u003Ccode\u003E--env-file=.env.local\u003C\/code\u003E.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlop:\u003C\/strong\u003E Wrong import path in the migration script — plan said two levels up, actual file is one level up. Fixed by checking the filesystem.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlop:\u003C\/strong\u003E R2 credentials not set locally caused every image upload to fail. Fixed by making the S3 plugin conditional on env vars being present.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EFlop:\u003C\/strong\u003E Category slugs (\u003Ccode\u003Echeesecake\u003C\/code\u003E) don't match display names (\u003Ccode\u003Echeesecakes\u003C\/code\u003E) — caused silent \"no category found\" misses across three debug runs. Caught once both values were printed side by side.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EAudit:\u003C\/strong\u003E Pre-Plan 2 check found ESLint silently broken and 8 TypeScript errors in migration scripts — both fixed before moving on.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Ch2\u003EWhat we built today\u003C\/h2\u003E\n\u003Cp\u003ETwo plans in one session. The first half was Plan 1, Tasks 2 and 3: Tailwind CSS v4 with design tokens, Google Fonts wired in via \u003Ccode\u003Enext\/font\u003C\/code\u003E. No visible pages, but the design system is in place — every color, every font, every token is defined and usable from here on.\u003C\/p\u003E\n\u003Cp\u003EBut equally as much of the first half was spent on something that doesn't show up in \u003Ccode\u003Egit diff\u003C\/code\u003E: prompt and context management.\u003C\/p\u003E\n\u003Cp\u003EBetween tasks I was running slash commands explicitly:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003E\/claude-md-management:claude-md-improver\u003C\/code\u003E — structured audit of CLAUDE.md against a quality rubric. Scores each section, lists specific issues, proposes targeted diffs. Not \"update the docs\" but \"here are three concrete things wrong and here's exactly what to change.\"\u003C\/li\u003E\n\u003Cli\u003EExplicit prompts after each task: \u003Cem\u003E\"have you updated memory and documentation?\"\u003C\/em\u003E — because Claude won't do this unprompted. It finishes the task and moves on. You have to ask.\u003C\/li\u003E\n\u003Cli\u003E\u003Cem\u003E\"tell me what you're going to do first\"\u003C\/em\u003E before every task — forcing a plan summary before any code is written. This catches misunderstandings before they become wrong commits, not after.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003EThe audit caught real things. After Task 3, CLAUDE.md had \u003Ccode\u003Enpx vitest\u003C\/code\u003E as the session-start test command — already stale because we'd just changed the test script to \u003Ccode\u003Enpm test\u003C\/code\u003E. It had two missing test directories. It had a filename discrepancy between the plan (\u003Ccode\u003E.ts\u003C\/code\u003E) and the actual file on disk (\u003Ccode\u003E.mts\u003C\/code\u003E). None of those would have caused immediate failures. All of them would have quietly confused a future session.\u003C\/p\u003E\n\u003Cp\u003EThe pattern I'm running each task:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003EAsk what Claude is going to do — get confirmation before any code\u003C\/li\u003E\n\u003Cli\u003ELet it execute\u003C\/li\u003E\n\u003Cli\u003EVerify in the browser or terminal\u003C\/li\u003E\n\u003Cli\u003EAsk to update memory and CLAUDE.md\u003C\/li\u003E\n\u003Cli\u003EPeriodically run \u003Ccode\u003E\/claude-md-management:claude-md-improver\u003C\/code\u003E for a formal audit\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cp\u003EIt's overhead. It's also the difference between a codebase where AI sessions build on each other cleanly and one where each session starts by re-discovering what the last session did.\u003C\/p\u003E\n\u003Cp\u003EThe second half was Plan 0: the WordPress content migration. The job was to read the WXR export sitting in \u003Ccode\u003Eresources\/\u003C\/code\u003E, scrape recipe JSON-LD from the live site (because the Zip Recipes plugin stores ingredients and steps in custom DB tables that didn't export), download all the featured images, and bulk-import everything into Payload.\u003C\/p\u003E\n\u003Cp\u003EEight tasks. A single disposable TypeScript script at \u003Ccode\u003Esrc\/scripts\/migrate-wordpress.ts\u003C\/code\u003E. The plan was written in the previous session and handed to Claude at the start of this one.\u003C\/p\u003E\n\u003Cp\u003EThe script ended up being harder than the plan made it look. Not because the architecture was wrong — the architecture was right. But because the plan was written against an imagined schema, and the actual schema that got built in Plan 1 was different in half a dozen small but breaking ways.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003ETask 2: Tailwind CSS v4 with design tokens\u003C\/h2\u003E\n\u003Ch3\u003EWhat changed in v4\u003C\/h3\u003E\n\u003Cp\u003ETailwind v4 drops \u003Ccode\u003Etailwind.config.js\u003C\/code\u003E. Design tokens now live in a \u003Ccode\u003E@theme\u003C\/code\u003E block inside \u003Ccode\u003Eglobals.css\u003C\/code\u003E. The PostCSS plugin changed too — it's \u003Ccode\u003E@tailwindcss\/postcss\u003C\/code\u003E now, not \u003Ccode\u003Etailwindcss\u003C\/code\u003E. Simpler, fewer files.\u003C\/p\u003E\n\u003Cp\u003E\u003Ccode\u003Epostcss.config.mjs\u003C\/code\u003E:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-js\"\u003Eexport default {\n  plugins: {\n    '@tailwindcss\/postcss': {},\n  },\n}\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003E\u003Ccode\u003Esrc\/app\/globals.css\u003C\/code\u003E has all eight design colors, four font family vars, and a card border radius in the \u003Ccode\u003E@theme\u003C\/code\u003E block:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-css\"\u003E@import \"tailwindcss\";\n\n@theme {\n  --color-background: #FDFAF5;\n  --color-accent:     #C0622A;\n  \/* ... all eight colors ... *\/\n  --font-display: var(--font-playfair);\n  \/* ... four font vars ... *\/\n  --radius-card: 8px;\n}\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe font vars reference \u003Ccode\u003E--font-playfair\u003C\/code\u003E etc. — CSS custom properties that \u003Ccode\u003Enext\/font\u003C\/code\u003E will inject on the \u003Ccode\u003E\u0026lt;html\u0026gt;\u003C\/code\u003E element. They're empty until Task 3 wires up the root layout.\u003C\/p\u003E\n\u003Ch3\u003EFlop: the CSS import I didn't audit\u003C\/h3\u003E\n\u003Cp\u003EAfter updating \u003Ccode\u003E(frontend)\/layout.tsx\u003C\/code\u003E to import \u003Ccode\u003E..\/globals.css\u003C\/code\u003E and deleting \u003Ccode\u003Estyles.css\u003C\/code\u003E, the dev server returned:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EModule not found: Can't resolve '.\/styles.css'\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EI'd updated the layout. What I hadn't checked was \u003Ccode\u003Epage.tsx\u003C\/code\u003E. The scaffold's \u003Ccode\u003E(frontend)\/page.tsx\u003C\/code\u003E had its own \u003Ccode\u003Eimport '.\/styles.css'\u003C\/code\u003E — a completely separate reference that wasn't in scope when I was thinking about the layout.\u003C\/p\u003E\n\u003Cp\u003EFix: remove the import from \u003Ccode\u003Epage.tsx\u003C\/code\u003E. One \u003Ccode\u003Egrep -r \"styles.css\" src\/\u003C\/code\u003E would have caught it before the server started.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ERoot cause:\u003C\/strong\u003E Thought about the layout file, didn't think to audit all files that imported the old CSS. Obvious in hindsight.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003ETask 3: Google Fonts and root layout\u003C\/h2\u003E\n\u003Ch3\u003EThe root layout\u003C\/h3\u003E\n\u003Cp\u003EThe scaffold doesn't create a root \u003Ccode\u003Esrc\/app\/layout.tsx\u003C\/code\u003E — only the route-group layouts. This task adds the real root layout, which loads all four Google Fonts and attaches their CSS variables to \u003Ccode\u003E\u0026lt;html\u0026gt;\u003C\/code\u003E:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Econst playfair = Playfair_Display({\n  subsets: ['latin'],\n  variable: '--font-playfair',\n  display: 'swap',\n})\n\/\/ ... lora, sourceSans, dmSans same pattern ...\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n  return (\n    \u0026lt;html\n      lang=\"en\"\n      className={`${playfair.variable} ${lora.variable} ${sourceSans.variable} ${dmSans.variable}`}\n    \u0026gt;\n      \u0026lt;body\u0026gt;{children}\u0026lt;\/body\u0026gt;\n    \u0026lt;\/html\u0026gt;\n  )\n}\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003E\u003Ccode\u003Enext\/font\u003C\/code\u003E downloads the font files at build time and serves them from the same origin — no Google DNS at runtime, no layout shift, no GDPR exposure from third-party font requests. The CSS variable mechanism is what connects the font load to the design tokens in \u003Ccode\u003Eglobals.css\u003C\/code\u003E.\u003C\/p\u003E\n\u003Cp\u003EWith the root layout owning \u003Ccode\u003E\u0026lt;html\u0026gt;\u003C\/code\u003E, \u003Ccode\u003E\u0026lt;body\u0026gt;\u003C\/code\u003E, and \u003Ccode\u003Eglobals.css\u003C\/code\u003E, the \u003Ccode\u003E(frontend)\/layout.tsx\u003C\/code\u003E becomes a one-liner passthrough:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Eexport default function FrontendLayout({ children }: { children: React.ReactNode }) {\n  return \u0026lt;\u0026gt;{children}\u0026lt;\/\u0026gt;\n}\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Ch3\u003EVitest config fix\u003C\/h3\u003E\n\u003Cp\u003EThe scaffold's \u003Ccode\u003Evitest.config.mts\u003C\/code\u003E was pointed at \u003Ccode\u003Etests\/int\/**\/*.int.spec.ts\u003C\/code\u003E with a \u003Ccode\u003Ejsdom\u003C\/code\u003E environment — a placeholder that matched none of the test files we're about to write. Replaced with:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-typescript\"\u003Eexport default defineConfig({\n  plugins: [tsconfigPaths()],\n  test: {\n    environment: 'node',\n    globals: true,\n    include: ['src\/__tests__\/**\/*.test.ts'],\n  },\n})\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003E\u003Ccode\u003Eenvironment: 'node'\u003C\/code\u003E is correct for collection config tests — they don't touch the DOM. Component tests will override this per-file with \u003Ccode\u003E\/\/ @vitest-environment jsdom\u003C\/code\u003E when needed.\u003C\/p\u003E\n\u003Cp\u003EAlso replaced the scaffold's \u003Ccode\u003Etest\u003C\/code\u003E script (which called \u003Ccode\u003Epnpm\u003C\/code\u003E) with:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-json\"\u003E\"test\": \"vitest run\",\n\"test:watch\": \"vitest\"\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Ch3\u003EVerification\u003C\/h3\u003E\n\u003Cp\u003EDev server loaded. Network tab showed four \u003Ccode\u003E.woff2\u003C\/code\u003E requests — Playfair Display, Lora, Source Sans 3, DM Sans. TypeScript clean. Committed.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003ETasks 4–14: Payload collections, globals, and Docker infrastructure\u003C\/h2\u003E\n\u003Cp\u003EAfter the design system work, we kept going. The rest of Plan 1 — all of it — in one session. Here's what that looked like.\u003C\/p\u003E\n\u003Ch3\u003EThe workflow: subagent-driven development\u003C\/h3\u003E\n\u003Cp\u003EFor Tasks 4–14 I used a different execution pattern than the first half of the day. Instead of Claude writing code in the main conversation, each task was dispatched to a fresh subagent:\u003C\/p\u003E\n\u003Col\u003E\n\u003Cli\u003E\u003Cstrong\u003EImplementer subagent\u003C\/strong\u003E gets the full task spec, context, and instructions. Follows TDD exactly: write failing test → verify it fails → implement → verify it passes → \u003Ccode\u003Etsc --noEmit\u003C\/code\u003E → commit.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESpec compliance reviewer\u003C\/strong\u003E reads the actual files and compares them line-by-line against the spec. Does not trust the implementer's report.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ECode quality reviewer\u003C\/strong\u003E reviews the diff for bugs, missing validations, and consistency issues.\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003Cp\u003EIf either reviewer finds problems, the implementer (or me directly for simple fixes) patches them and the reviewer runs again.\u003C\/p\u003E\n\u003Cp\u003EFresh subagent per task means no context bleed — the Workshops implementer doesn't remember anything from the Recipes implementer. Each one reads exactly the context it needs, nothing more. Eleven tasks, zero accumulated confusion.\u003C\/p\u003E\n\u003Ch3\u003EWhat got built\u003C\/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003ECollections (Tasks 4–8):\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ERecipes\u003C\/code\u003E — the core collection. 17 fields: title, slug, status, publishedAt, category relationship, tags, featuredImage, intro, prepTime, cookTime, servings, ingredients array, steps array, equipment array with affiliate URLs, seoTitle, seoDescription.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003ECategories\u003C\/code\u003E — simple taxonomy: name, slug, description, image upload.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003EWorkshops\u003C\/code\u003E — culinary classes. Title, slug, status (upcoming\/past), date, location, price, description, bookingUrl, image.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003EComments\u003C\/code\u003E — moderated via admin. Recipe relationship, authorName, authorEmail, body, \u003Ccode\u003Eapproved: false\u003C\/code\u003E by default.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003EMedia\u003C\/code\u003E — replaces the scaffold placeholder. Four image sizes: thumbnail (400×300), card (800×533), hero (1440×600), mobile (390×260). Cloudflare R2 storage adapter wired in.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003E\u003Cstrong\u003EGlobals (Tasks 9–10):\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ESiteSettings\u003C\/code\u003E — siteName, tagline, socialUrls group (facebook\/pinterest\/instagram\/youtube), newsletter heading\/subheading.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003ENavigation\u003C\/code\u003E — primaryNav and footerNav arrays of label+url pairs. Shared \u003Ccode\u003EnavLinkFields\u003C\/code\u003E const keeps them DRY.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003E\u003Cstrong\u003EInfrastructure (Tasks 11–14):\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EFull test suite: 7 test files, 18 tests, all green.\u003C\/li\u003E\n\u003Cli\u003EPayload types generated (\u003Ccode\u003Esrc\/payload-types.ts\u003C\/code\u003E, 773 lines) — Payload's type generator ran against the config without needing the dev server or database.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003EDockerfile\u003C\/code\u003E — 3-stage multi-stage build (deps → builder → runner), non-root \u003Ccode\u003Enextjs\u003C\/code\u003E user.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Edocker-compose.yml\u003C\/code\u003E — expanded from the minimal db-only version to the full stack: app + db + caddy.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003ECaddyfile\u003C\/code\u003E — one-liner: \u003Ccode\u003Emyloveofbaking.com { reverse_proxy app:3000 }\u003C\/code\u003E.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003E.github\/workflows\/deploy.yml\u003C\/code\u003E — build Docker image → push to GHCR → SSH to server → \u003Ccode\u003Edocker compose pull \u0026amp;\u0026amp; up -d\u003C\/code\u003E.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003E.env.example\u003C\/code\u003E — all 20+ env vars documented.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003ETen commits. All green.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch3\u003EAI flops (Plan 1)\u003C\/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EForward references in TypeScript\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cp\u003EPayload's \u003Ccode\u003ECollectionConfig\u003C\/code\u003E types require \u003Ccode\u003ErelationTo\u003C\/code\u003E to be a key from the \u003Ccode\u003ECollectionSlug\u003C\/code\u003E union — which is auto-generated and empty until \u003Ccode\u003Enpm run generate:types\u003C\/code\u003E runs. So when \u003Ccode\u003ERecipes\u003C\/code\u003E references \u003Ccode\u003E'categories'\u003C\/code\u003E before \u003Ccode\u003ECategories\u003C\/code\u003E exists, TypeScript throws:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EType '\"categories\"' is not assignable to type 'CollectionSlug'\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe fix the implementer used: \u003Ccode\u003ErelationTo: 'categories' as any\u003C\/code\u003E. Fine, temporary, documented with an \u003Ccode\u003Eeslint-disable\u003C\/code\u003E comment. After Task 11 generates types, the cast is technically still needed because \u003Ccode\u003ECollectionSlug\u003C\/code\u003E won't update again until the next build. The casts stay in place until someone runs \u003Ccode\u003Enpm run generate:types\u003C\/code\u003E and commits the result again.\u003C\/p\u003E\n\u003Cp\u003EThis is a known Payload v3 TDD friction point: you can't write TypeScript-clean relationship fields until all collections exist and types are generated. The plan didn't mention it. The implementer handled it correctly, but it should be in the gotchas list.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe \u003Ccode\u003E.env.example\u003C\/code\u003E commit that went nowhere\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cp\u003ETask 14's subagent reported: \u003Cem\u003E\"Committed with: \u003Ccode\u003Edocs: add .env.example\u003C\/code\u003E\"\u003C\/em\u003E. Git log showed no such commit. \u003Ccode\u003Els .env.example\u003C\/code\u003E returned nothing.\u003C\/p\u003E\n\u003Cp\u003EThe agent had probably run in the wrong directory or committed to a detached state. No error, no complaint, just a ghost commit that never landed.\u003C\/p\u003E\n\u003Cp\u003EI created the file directly and committed it myself. Two minutes to fix, but the pattern is worth noting: when an agent reports a commit that doesn't appear in \u003Ccode\u003Egit log\u003C\/code\u003E, the commit didn't happen. Always verify with \u003Ccode\u003Egit log\u003C\/code\u003E before moving on, not after.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EPre-existing TypeScript noise\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cp\u003EEvery \u003Ccode\u003Etsc --noEmit\u003C\/code\u003E run in every task reported three errors about \u003Ccode\u003Esrc\/app\/(payload)\/admin\/importMap.js\u003C\/code\u003E:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003Eerror TS2307: Cannot find module '..\/importMap'\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThis is Payload's auto-generated file — it exists at runtime but not in the repo (gitignored). It produces the same three errors whether the project is empty or fully implemented, and it has nothing to do with anything we're building. Every implementer spent cycles confirming these errors were pre-existing before reporting DONE. That's the right behavior, but it added noise to every single task report.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch3\u003EAI wins (Plan 1)\u003C\/h3\u003E\n\u003Cp\u003E\u003Cstrong\u003EURL validation catch — \u003Ccode\u003EaffiliateUrl\u003C\/code\u003E in Recipes equipment\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cp\u003EThe plan spec said \u003Ccode\u003EaffiliateUrl\u003C\/code\u003E was a \u003Ccode\u003Etext\u003C\/code\u003E field. The code quality reviewer flagged that with no validation, a content editor could save a plain product name or a bare ASIN and it would silently produce a broken \u003Ccode\u003E\u0026lt;a href\u0026gt;\u003C\/code\u003E later. The reviewer suggested a \u003Ccode\u003Evalidate\u003C\/code\u003E function using \u003Ccode\u003Enew URL()\u003C\/code\u003E. Implemented. Took 10 minutes. This is exactly the kind of thing a thorough code review catches that a fast implementation skips.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E\u003Ccode\u003Eindex: true\u003C\/code\u003E on slug fields\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cp\u003ESame reviewer flagged that Recipes' \u003Ccode\u003Eslug\u003C\/code\u003E field had \u003Ccode\u003Eunique: true\u003C\/code\u003E but no \u003Ccode\u003Eindex: true\u003C\/code\u003E. PostgreSQL creates an implicit index for UNIQUE constraints, but declaring \u003Ccode\u003Eindex: true\u003C\/code\u003E explicitly signals query intent and is the Payload v3 pattern for slug-based lookups. Caught in Task 4, applied as the project pattern in Tasks 5–6.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EMedia collection: scaffold replacement without ceremony\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cp\u003EThe scaffold ships a \u003Ccode\u003EMedia\u003C\/code\u003E collection with \u003Ccode\u003Eupload: true\u003C\/code\u003E and nothing else — no image sizes, no storage config. The implementer correctly identified this as a placeholder, replaced it entirely with the spec-defined version (four image sizes, alt text, R2 adapter), and handled the scaffold's \u003Ccode\u003Eaccess: { read: () =\u0026gt; true }\u003C\/code\u003E drop correctly: R2 serves images directly from a public URL, bypassing Payload's access control layer.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003E18\/18 tests on first full run\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cp\u003EAfter all collections and globals were in place, \u003Ccode\u003Enpm test\u003C\/code\u003E ran clean on the first try. Every collection test, every global test, no failures. That's TDD doing what it's supposed to do — you don't find out if the collection config is wrong at page-render time, you find out immediately.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003EThe schema divergence problem (Plan 0)\u003C\/h2\u003E\n\u003Cp\u003EThe migration plan was written before Plan 1 executed. When Plan 1 built the Recipes collection, it made sensible field naming decisions that didn't match what the plan had assumed:\u003C\/p\u003E\n\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EPlan spec assumed\u003C\/th\u003E\n\u003Cth\u003EActual schema\u003C\/th\u003E\n\u003C\/tr\u003E\n\u003C\/thead\u003E\n\u003Ctbody\u003E\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Efeatured_image\u003C\/code\u003E\u003C\/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EfeaturedImage\u003C\/code\u003E\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eprep_time\u003C\/code\u003E (text \"20 min\")\u003C\/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EprepTime\u003C\/code\u003E (number, minutes)\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Ecook_time\u003C\/code\u003E (text)\u003C\/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EcookTime\u003C\/code\u003E (number, minutes)\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eservings\u003C\/code\u003E (string)\u003C\/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eservings\u003C\/code\u003E (number)\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eseo_title\u003C\/code\u003E\u003C\/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EseoTitle\u003C\/code\u003E\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eseo_description\u003C\/code\u003E\u003C\/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EseoDescription\u003C\/code\u003E\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Esteps[].text\u003C\/code\u003E\u003C\/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Esteps[].instruction\u003C\/code\u003E\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Etags: string[]\u003C\/code\u003E\u003C\/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etags: { tag: string }[]\u003C\/code\u003E\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003C\/tbody\u003E\u003C\/table\u003E\n\u003Cp\u003EThe first attempt at Task 7 failed immediately with \u003Ccode\u003ETypeError: Cannot create property 'tag' on string 'baking'\u003C\/code\u003E. That was the tags mismatch. Fix deployed. Then it ran fine for two recipes before hitting a wall on \u003Ccode\u003Eingredient.quantity\u003C\/code\u003E — required field, empty string, validation error. Turns out a lot of recipe ingredients don't have a measurable quantity. \"Salt to taste\" doesn't have a quantity. The schema said otherwise.\u003C\/p\u003E\n\u003Cp\u003EThen 60 recipes failed with category mismatches. The WXR \u003Ccode\u003E\u0026lt;category\u0026gt;\u003C\/code\u003E element has two representations of the category — a \u003Ccode\u003Enicename\u003C\/code\u003E attribute (the slug) and CDATA text (the display name). The plan said to use \u003Ccode\u003Ec['__cdata']\u003C\/code\u003E, which is the display name \"Dessert\" — but the ID map was built from the slug \u003Ccode\u003Edessert\u003C\/code\u003E. All lookup attempts returned undefined.\u003C\/p\u003E\n\u003Cp\u003EThen JSON-LD instructions. Schema.org \u003Ccode\u003ErecipeInstructions\u003C\/code\u003E can be a string, an array of HowToStep, or an array of HowToSection containing nested HowToStep entries. The plan assumed plain array. Several recipes use HowToSection. The \u003Ccode\u003E?? []\u003C\/code\u003E fallback doesn't help when the value is a non-null non-array.\u003C\/p\u003E\n\u003Cp\u003EIn total: five debug-fix cycles to get from first attempt to clean run. None of these were catastrophic — the script is idempotent, so each re-run picked up where it left off, creating only the recipes that hadn't been created yet. 68 created, then 50 more, then 10 more, until 0 created and 130 skipped.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003EAI flops (Plan 0)\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EGhost env var.\u003C\/strong\u003E First Payload initialization attempt:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EError: missing secret key. A secret key is needed to secure Payload.\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe \u003Ccode\u003E.env.local\u003C\/code\u003E file exists and has \u003Ccode\u003EPAYLOAD_SECRET\u003C\/code\u003E, but \u003Ccode\u003Etsx\u003C\/code\u003E doesn't load it by default. Fix is \u003Ccode\u003Enpx tsx --env-file=.env.local\u003C\/code\u003E. This is documented in CLAUDE.md now. But the plan said \u003Ccode\u003Enpx tsx src\/scripts\/migrate-wordpress.ts\u003C\/code\u003E with no mention of env loading — Claude generated the command from the plan without checking whether \u003Ccode\u003Etsx\u003C\/code\u003E actually picks up \u003Ccode\u003E.env.local\u003C\/code\u003E by default (it doesn't).\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EWrong import path.\u003C\/strong\u003E \u003Ccode\u003Epayload.config\u003C\/code\u003E lives at \u003Ccode\u003Esrc\/payload.config.ts\u003C\/code\u003E. The migration script is at \u003Ccode\u003Esrc\/scripts\/migrate-wordpress.ts\u003C\/code\u003E. The plan said \u003Ccode\u003Eimport config from '..\/..\/payload.config'\u003C\/code\u003E — two levels up, which puts you at the project root. The actual file is one level up: \u003Ccode\u003E..\/payload.config\u003C\/code\u003E. Plan was wrong; Claude followed the plan without checking the filesystem.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ER2 blocking uploads.\u003C\/strong\u003E The Payload Media collection is wired to S3\/R2 via \u003Ccode\u003E@payloadcms\/storage-s3\u003C\/code\u003E. Running locally without R2 credentials, every image upload failed:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003EError: No value provided for input HTTP label: Bucket.\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe plan said \"R2 must be configured before running the migration.\" It's not configured yet. The right fix: make the S3 plugin conditional on the env vars being present. One change to \u003Ccode\u003Epayload.config.ts\u003C\/code\u003E — spread the plugin into the array only when all four R2 vars exist. Local dev now falls back to the \u003Ccode\u003Euploads\/\u003C\/code\u003E directory. This should have been in the plan from the start; it's a standard pattern for dev\/prod parity.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ECategory display name vs. slug.\u003C\/strong\u003E The worst debugging session of the day. Recipes were being skipped with \"no category found\" despite having categories in WXR. The category ID map keys are slugs (\u003Ccode\u003Edessert\u003C\/code\u003E). The post category extraction used \u003Ccode\u003Ec['__cdata']\u003C\/code\u003E which gives the display name (\u003Ccode\u003EDessert\u003C\/code\u003E). These look similar enough that you'd need to actually print both to spot the mismatch. It took three runs before I looked at what was actually in the map vs. what was being looked up.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003EAI wins (Plan 0)\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EIdempotency worked perfectly.\u003C\/strong\u003E The script checked for existing slugs before creating. Every failed recipe in one run could be retried in the next without duplicating anything. Over five runs, it accumulated: 2 → 68 → 118 → 120 → 130. No manual cleanup needed between any of them.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ESchema violations surfaced fast.\u003C\/strong\u003E Payload's validation errors were precise: \u003Ccode\u003EThe following field is invalid: Ingredients 7 \u0026gt; Name\u003C\/code\u003E. Not just \"create failed\" — it told me exactly which field, which row, and what was wrong. Claude correctly parsed that into \"the ingredient name is empty\" and traced it back to the \u003Ccode\u003EparseIngredient\u003C\/code\u003E helper returning \u003Ccode\u003E{ quantity: '', name: '' }\u003C\/code\u003E when the raw string was just whitespace.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EThe plan's structure held.\u003C\/strong\u003E Even though field names were wrong and several edge cases weren't covered, the overall architecture — parse WXR, scrape JSON-LD, upload images, create categories first, create recipes last — was exactly right. The bugs were in the details, not the design.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003EPre-Plan 2 audit: things that had been broken the whole time\u003C\/h2\u003E\n\u003Cp\u003EBefore starting Plan 2, I asked Claude to run a full verification pass — tests, linter, TypeScript — and check every infrastructure file against the Plan 1 spec. Good thing we did.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EESLint was completely broken.\u003C\/strong\u003E Not \"a few warnings\" broken. It crashed on startup with a circular structure error and produced zero output:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode\u003ETypeError: Converting circular structure to JSON\n    --\u0026gt; starting at object with constructor 'Object'\n    |     property 'configs' -\u0026gt; object with constructor 'Object'\n    |     ...\n    --- property 'react' closes the circle\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EThe root cause: \u003Ccode\u003Eeslint.config.mjs\u003C\/code\u003E was using \u003Ccode\u003EFlatCompat\u003C\/code\u003E to wrap \u003Ccode\u003Enext\/core-web-vitals\u003C\/code\u003E and \u003Ccode\u003Enext\/typescript\u003C\/code\u003E. This was the Next.js-recommended pattern for ESLint 9 flat config — but \u003Ccode\u003Eeslint-config-next\u003C\/code\u003E v16 now exports native flat config arrays. Running a native flat config through \u003Ccode\u003EFlatCompat\u003C\/code\u003E creates circular references when the validator tries to JSON-stringify the config for inspection. ESLint crashes before it reads a single file.\u003C\/p\u003E\n\u003Cp\u003EThe fix was removing \u003Ccode\u003EFlatCompat\u003C\/code\u003E entirely and importing the configs directly:\u003C\/p\u003E\n\u003Cpre\u003E\u003Ccode class=\"language-js\"\u003E\/\/ Before (broken)\nimport { FlatCompat } from '@eslint\/eslintrc'\nconst compat = new FlatCompat({ baseDirectory: __dirname })\nconst eslintConfig = [...compat.extends('next\/core-web-vitals', 'next\/typescript'), ...]\n\n\/\/ After (working)\nimport coreWebVitals from 'eslint-config-next\/core-web-vitals'\nimport typescript from 'eslint-config-next\/typescript'\nconst eslintConfig = [...coreWebVitals, ...typescript, ...]\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cp\u003EAlso had to add \u003Ccode\u003Edesign\/\u003C\/code\u003E to the ESLint ignores — the design canvas JSX files aren't application code and were producing hundreds of \u003Ccode\u003Ereact\/jsx-no-undef\u003C\/code\u003E errors once the linter actually ran.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EEight TypeScript errors in the migration script.\u003C\/strong\u003E The script had run successfully and created 130 recipes — \u003Ccode\u003Etsx\u003C\/code\u003E doesn't enforce types at runtime, so these were invisible during execution:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003Edoc.id as string\u003C\/code\u003E on numeric PostgreSQL IDs — you can't assert \u003Ccode\u003Enumber as string\u003C\/code\u003E. Should be \u003Ccode\u003EString(doc.id)\u003C\/code\u003E.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Ecategory: categoryPayloadId\u003C\/code\u003E — the map stored IDs as strings, but Payload's relation field expects a number. Should be \u003Ccode\u003ENumber(categoryPayloadId)\u003C\/code\u003E.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003EbuildLexicalParagraph\u003C\/code\u003E returned \u003Ccode\u003Edirection: 'ltr'\u003C\/code\u003E typed as \u003Ccode\u003Estring\u003C\/code\u003E, but Payload's Lexical type wants \u003Ccode\u003E\"ltr\" | \"rtl\" | null\u003C\/code\u003E. Fix: \u003Ccode\u003Edirection: 'ltr' as const\u003C\/code\u003E.\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Epayload.db.destroy()\u003C\/code\u003E — TypeScript correctly flagged that \u003Ccode\u003Edestroy\u003C\/code\u003E might not exist on the db adapter. Fix: \u003Ccode\u003Epayload.db?.destroy?.()\u003C\/code\u003E.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003ENone of these affected the data already in Payload — the migration ran fine despite the type errors. But they would have silently misled anyone trying to understand the script, and the category-as-string error would have caused real issues if the script were ever re-run after Payload's types changed.\u003C\/p\u003E\n\u003Cp\u003E\u003Cstrong\u003ETests: 18\/18 passing. Infrastructure: all present.\u003C\/strong\u003E The actual functionality from Plans 0 and 1 checks out. The issues were all in the tooling layer.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003EEnd of day\u003C\/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EDone:\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ETailwind CSS v4 installed with PostCSS config\u003C\/li\u003E\n\u003Cli\u003EAll design tokens defined in \u003Ccode\u003Eglobals.css\u003C\/code\u003E (\u003Ccode\u003E@theme\u003C\/code\u003E block)\u003C\/li\u003E\n\u003Cli\u003ERoot layout created with four Google Fonts via \u003Ccode\u003Enext\/font\u003C\/code\u003E\u003C\/li\u003E\n\u003Cli\u003EFont CSS variables connected to design tokens\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003E(frontend)\/layout.tsx\u003C\/code\u003E thinned to a passthrough\u003C\/li\u003E\n\u003Cli\u003EVitest pointed at the correct test directory with node environment\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERecipes, Categories, Workshops, Comments, Media collections\u003C\/strong\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESiteSettings, Navigation globals\u003C\/strong\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003E18\/18 tests passing\u003C\/strong\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EPayload types generated (773 lines)\u003C\/strong\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EDockerfile + docker-compose.yml (full stack) + Caddyfile\u003C\/strong\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EGitHub Actions CI\/CD workflow\u003C\/strong\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003E.env.example documenting all 20+ env vars\u003C\/strong\u003E\u003C\/li\u003E\n\u003Cli\u003E130 recipes in Payload with ingredients, steps, categories, featured images, SEO fields\u003C\/li\u003E\n\u003Cli\u003E16 categories\u003C\/li\u003E\n\u003Cli\u003E130 media items (all featured images)\u003C\/li\u003E\n\u003Cli\u003EMigration script is idempotent, documented, and re-runnable\u003C\/li\u003E\n\u003Cli\u003E7 informational posts have placeholder \u003Ccode\u003E\"See original recipe\"\u003C\/code\u003E ingredients — flagged in \u003Ccode\u003EMIGRATION-NOTES.md\u003C\/code\u003E for manual follow-up\u003C\/li\u003E\n\u003Cli\u003E\u003Ccode\u003Epayload.config.ts\u003C\/code\u003E updated to gate R2 on env vars — local dev no longer requires R2 credentials\u003C\/li\u003E\n\u003Cli\u003EESLint fixed — \u003Ccode\u003EFlatCompat\u003C\/code\u003E removed, \u003Ccode\u003Edesign\/\u003C\/code\u003E ignored\u003C\/li\u003E\n\u003Cli\u003E8 TypeScript errors in migration scripts fixed — type-safe ID conversions, \u003Ccode\u003Eas const\u003C\/code\u003E on Lexical literals, optional chaining on db destroy\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003E\u003Cstrong\u003ENot done:\u003C\/strong\u003E\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EAny public-facing pages (Plan 2)\u003C\/li\u003E\n\u003Cli\u003EprepTime is 0 for most recipes — the live site's JSON-LD often omits \u003Ccode\u003EprepTime\u003C\/code\u003E. Expected; content filled in through admin over time.\u003C\/li\u003E\n\u003Cli\u003ER2 credentials in actual env files\u003C\/li\u003E\n\u003Cli\u003EGitHub Actions secrets (DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY) — manual step\u003C\/li\u003E\n\u003Cli\u003EDocker full-stack test against a real build\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003E\u003Cstrong\u003ENext:\u003C\/strong\u003E Plan 2. Build all 11 pages against the real data.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003EContext engineering: the meta-layer I'm building as we go\u003C\/h2\u003E\n\u003Cp\u003ESomething I want to document while I'm still doing it consciously, because I think it's the part most people skip when they write about AI-assisted development.\u003C\/p\u003E\n\u003Cp\u003EBetween tasks today I stopped and asked: \u003Cem\u003E\"have you updated memory and documentation?\"\u003C\/em\u003E\u003C\/p\u003E\n\u003Cp\u003EClaude hadn't. The execution state memory still said Task 2 was in progress. CLAUDE.md still said Task 2 was next. Left unchecked, the next session would have started with stale context and potentially re-done work or made wrong assumptions. I also triggered the \u003Ccode\u003E\/claude-md-management:claude-md-improver\u003C\/code\u003E skill explicitly — a structured audit that found three real issues: a stale \u003Ccode\u003Enpx vitest\u003C\/code\u003E command, two missing test directories, and an undocumented filename mismatch between the plan and the actual file on disk.\u003C\/p\u003E\n\u003Cp\u003ENone of those are catastrophic bugs. But each one is a small drift — a tiny gap between what Claude thinks is true and what's actually true. Drift compounds. By session five or six, a codebase full of small drifts starts producing confusing errors and contradictory suggestions.\u003C\/p\u003E\n\u003Cp\u003EWhat I'm doing — and I'm being deliberate about this — is \u003Cstrong\u003Econtext engineering\u003C\/strong\u003E. Not prompt engineering in the \"magic words to get better output\" sense. Actual engineering: designing and maintaining the information environment that the AI operates in.\u003C\/p\u003E\n\u003Cp\u003EThe setup has several layers:\u003C\/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EImplementation plans\u003C\/strong\u003E in \u003Ccode\u003Edocs\/superpowers\/plans\/\u003C\/code\u003E — committed to disk, read at the start of every session. These survive context compaction because they're files, not conversation history.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EMemory files\u003C\/strong\u003E in \u003Ccode\u003E.claude\/projects\/...\/memory\/\u003C\/code\u003E — structured records of execution state, project decisions, user preferences, and deviations from plan. Written at the end of each session, read at the start of the next.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ECLAUDE.md\u003C\/strong\u003E — the always-loaded project context. Commands, gotchas, architecture, conventions. Audited with a dedicated skill when things change.\u003C\/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESkills\u003C\/strong\u003E — invokable workflows for recurring activities. \u003Ccode\u003E\/claude-md-management:claude-md-improver\u003C\/code\u003E for CLAUDE.md audits. \u003Ccode\u003Ecommit-commands:commit\u003C\/code\u003E for consistent commit patterns. \u003Ccode\u003Esuperpowers:verification-before-completion\u003C\/code\u003E before claiming anything is done.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cp\u003EThe key insight is that AI context is a resource, not a given. It degrades. It gets stale. It gets compressed. If you don't actively manage it — writing things down, updating memory, auditing documentation — you're relying on the model to remember things it structurally cannot remember across sessions.\u003C\/p\u003E\n\u003Cp\u003EMost people use AI assistants like a smart colleague you can ask anything. That works for a one-off question. For a multi-week project with real architecture decisions and real gotchas, you need to treat context like infrastructure: design it deliberately, maintain it, check it for drift.\u003C\/p\u003E\n\u003Cp\u003EThe overhead is real. Stopping mid-session to ask \"update the memory and CLAUDE.md\" takes a few minutes. Triggering a CLAUDE.md audit takes longer. But the alternative is a compounding tax — every future session starting with slightly wrong assumptions, every small drift requiring slightly more correction.\u003C\/p\u003E\n\u003Cp\u003EI'm spending maybe 10% of each session on context maintenance. It feels like it's already paying off.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Ch2\u003EHonest take\u003C\/h2\u003E\n\u003Cp\u003ELong day. Two plans done in one session.\u003C\/p\u003E\n\u003Cp\u003EPlan 1 closed cleanly: every collection, every global, Docker, CI\/CD, env documentation, 18 tests. The subagent workflow was the thing that made it possible. For the collections work specifically, it kept the main conversation clean while 11 separate implementers each did exactly one thing. The quality reviews caught real issues (the \u003Ccode\u003EaffiliateUrl\u003C\/code\u003E validation, the slug index pattern) that would have been missing if I'd just accepted the first implementation. Fresh context per task is not overhead — it's the mechanism that keeps the implementation focused and the code consistent.\u003C\/p\u003E\n\u003Cp\u003EPlan 0 was messier. The pattern: plan meets reality, plan loses details, Claude dutifully implements the wrong details, we iterate. What works — the iteration loop is fast. An error message → Claude identifies the root cause → fix is two lines → re-run. The five debug cycles took maybe 30 minutes total. On a human-only implementation, finding the nicename\/CDATA mismatch would have meant carefully reading WXR documentation and staring at the object structure. Claude spotted it by looking at what was actually in the variables at runtime — same as you'd do, just faster.\u003C\/p\u003E\n\u003Cp\u003EWhat's still fragile: plans that were written against a spec that later changed. The migration plan was good but it was written in session 1 against a mental model of what the schema would look like. By session 2, the actual schema had slightly different field names. The plan didn't update. Claude implemented the plan, not the schema. This is a documentation problem — keeping plans and code in sync — and I don't have a clean answer for it yet.\u003C\/p\u003E\n\u003Cp\u003EThe ghost \u003Ccode\u003E.env.example\u003C\/code\u003E commit is the flop I want to hold onto from the first half. The agent reported success, there was no error, and I almost moved on. The right discipline: after any commit claim, run \u003Ccode\u003Egit log\u003C\/code\u003E and verify the SHA actually appears. AI agents are occasionally optimistic about whether an action succeeded. Check the evidence.\u003C\/p\u003E\n\u003Cp\u003EThe audit added a different lesson. The linter had been broken since the project started — every \u003Ccode\u003Enpm run lint\u003C\/code\u003E during Plan 1 and Plan 0 would have crashed immediately. Nobody noticed because we weren't running it as a gate. The fix: the pre-phase audit. Before starting Plan 2, we ran everything — \u003Ccode\u003Enpm test\u003C\/code\u003E, \u003Ccode\u003Enpm run lint\u003C\/code\u003E, \u003Ccode\u003Enpx tsc --noEmit\u003C\/code\u003E — and treated any failure as blocking. It caught real problems. Verification should be a gate, not an afterthought.\u003C\/p\u003E\n\u003Chr \/\u003E\n\u003Cp\u003E\u003Cem\u003EThis is part of an ongoing series documenting the rebuild of a friend's food blog from WordPress to a custom Next.js stack, built with AI assistance.\u003C\/em\u003E\u003C\/p\u003E\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/8189389435439042777\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/06\/day-0203-tailwind-fonts-and-schema-that.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/8189389435439042777"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/8189389435439042777"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/06\/day-0203-tailwind-fonts-and-schema-that.html","title":"Day 02–03 — Tailwind, Fonts, and the Schema That Lied to the Plan"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjE9soCj03hb6FbP2ucX7WH16H3PCpMnFL6DFK5Y6loSjNcLUmBdwaczfa9wGc3BF5I6co44NT9lCXISsi54ziVLpj_pnaH1PfuFQfkLnylDAiXrjiiYGQ76bvV467mMZUDymJpj-Mt95y1GS_Bf4x-yhALls9Bw8r7z9uxQekq94TLs7CNdqkdfKmiDz4\/s72-c\/day02.png","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-7069907650143637205"},"published":{"$t":"2026-05-23T08:33:13.074-07:00"},"updated":{"$t":"2026-06-05T17:47:01.597-07:00"},"category":[{"scheme":"http://www.blogger.com/atom/ns#","term":"featured"},{"scheme":"http://www.blogger.com/atom/ns#","term":"VibeEngineering"}],"title":{"type":"text","$t":"Day 01 — Tearing Down the WordPress House"},"content":{"type":"html","$t":"\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhSWU3SJwC_AdP363kGd_DEjnXiArI-590wypUX0vPx4707EcUqR7_Y8giXG_Jpp2cn4TRqnuPCEpdRaX_g-yZVYL_aTbep84n3JUhLpyXZknyjFZrRIxtMUCk62TODZSCeX9MkRYMMbVyDITHyZhUj3tEUCQHUsfYc7vIkOclTXSwhIMrhQNEXLtzBftU\/s1536\/c5592c31-c7a3-480a-a99f-7af1f12437aa.png\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1024\" data-original-width=\"1536\" height=\"213\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhSWU3SJwC_AdP363kGd_DEjnXiArI-590wypUX0vPx4707EcUqR7_Y8giXG_Jpp2cn4TRqnuPCEpdRaX_g-yZVYL_aTbep84n3JUhLpyXZknyjFZrRIxtMUCk62TODZSCeX9MkRYMMbVyDITHyZhUj3tEUCQHUsfYc7vIkOclTXSwhIMrhQNEXLtzBftU\/s320\/c5592c31-c7a3-480a-a99f-7af1f12437aa.png\" width=\"320\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cbr \/\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cp\u003E\u003Cb\u003EDate:\u003C\/b\u003E 2026-05-22\u0026nbsp;\u0026nbsp;\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003EStatus at end of day:\u003C\/b\u003E Scaffold live. Payload admin at `\/admin` returns 200. Zero pages built.\u003C\/p\u003E\u003Ch3 style=\"text-align: left;\"\u003EWhy we're doing this\u003C\/h3\u003E\u003Cp\u003EA friend of mine has been running her food blog on WordPress for years. It works, mostly. But \"works, mostly\" starts to feel like a cage when you look at what it costs — not just in dollars (hosting renewals that only go up) but in the invisible tax of a platform you don't control. Slow admin. Plugin soup. A theme that fights you every time you try to change something. And 90% of the traffic is mobile, but the site wasn't really built for that.\u003C\/p\u003E\u003Cp\u003ESo we're rebuilding it from scratch. Custom Next.js app, Payload CMS, PostgreSQL, Tailwind CSS, Docker. The whole thing designed around how she actually uses it — she manages content herself and is technical enough to handle a real admin UI, she just doesn't want to write code.\u003C\/p\u003E\u003Cp\u003EAnd we're doing it with AI. Specifically, Claude Code doing most of the heavy lifting while I drive.\u003C\/p\u003E\u003Ch3 style=\"text-align: left;\"\u003EThe planning phase: where AI genuinely earns its keep\u003C\/h3\u003E\u003Cp\u003EBefore a single line of code was written, we spent time in a design session. And this is where AI-assisted development surprised me the most on day one — not in the code writing, but in the \u003Cb\u003Edecisions\u003C\/b\u003E.\u003C\/p\u003E\u003Cp\u003EI had a rough idea of what I wanted. Claude asked questions one at a time. Not \"what do you want to build?\" but specific things: what's her content management workflow, how technically capable is she, what are the monetisation constraints, what does the traffic look like. Each question built on the last.\u003C\/p\u003E\u003Cp\u003EBy the end of maybe 45 minutes of back-and-forth, we had:\u003C\/p\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003EA full tech stack decision with reasoning for each choice\u003C\/li\u003E\u003Cli\u003EA rendering strategy per page (SSG vs ISR with specific revalidation times)\u003C\/li\u003E\u003Cli\u003EPayload CMS collections and globals defined\u003C\/li\u003E\u003Cli\u003EAPI routes listed\u003C\/li\u003E\u003Cli\u003EInfrastructure decisions made\u003C\/li\u003E\u003Cli\u003EA 4-plan implementation sequence with a separate content migration script\u003C\/li\u003E\u003C\/ul\u003E\u003Cp\u003EThat planning work lives in `docs\/superpowers\/specs\/` and `docs\/superpowers\/plans\/`. Reading it back, it's better documented than most professional projects I've worked on. The AI didn't just execute instructions — it pushed back, asked \"why\", and caught things I hadn't thought through.\u003C\/p\u003E\u003Cp\u003EOne example: early in the session I told Claude I wanted to move off SiteGround — shared hosting we don't control, costs that only go up, a platform that doesn't fit what we're building. That instruction got swallowed somewhere in context compaction. Claude kept planning as if we were staying on SiteGround. I had to say it again before the hosting conversation actually happened.\u003C\/p\u003E\u003Cp\u003EOnce it landed, we got to the right answer: Docker Compose + Caddy. The entire stack moves to any Linux box with a single `git pull` and `docker compose up`. SiteGround, a home server, a DigitalOcean droplet — doesn't matter. But I shouldn't have had to say it twice.\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003EAI limitation: \u003C\/b\u003EContext compaction is real. In a long planning session, earlier instructions can get dropped from the active window. If something matters — a constraint, a direction, a decision — say it again when it becomes relevant, or write it down in a plan that gets committed to disk.\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003EAI advantage: \u003C\/b\u003EOnce the context was right, the planning quality was high. Architecture documentation, rendering strategies, API routes, Payload collections — all thought through and committed in one session. Better-structured than most professional projects I've seen.\u003C\/p\u003E\u003Ch3 style=\"text-align: left;\"\u003EInfrastructure: the decision I didn't know I needed to make\u003C\/h3\u003E\u003Cp\u003EThe hosting conversation I'd tried to have earlier — the one context compaction had eaten — finally happened properly. Claude laid out three paths: Nginx + PM2 on the existing VPS (familiar, simple), Docker Compose + Caddy (portable, handles SSL automatically), or a managed platform like Vercel (easiest but locks you to a vendor and gets expensive).\u003C\/p\u003E\u003Cp\u003EI chose Docker. Not because I had a strong view — because the reasoning was clear. With Docker Compose, moving the whole stack is: update a GitHub secret with the new server IP, push a commit, done. No reinstalling Nginx. No regenerating SSL certificates. No PM2 config to redo.\u003C\/p\u003E\u003Cp\u003ECaddy was a new one for me. It's a reverse proxy that automatically obtains and renews Let's Encrypt certificates. The entire SSL config is one line:\u003C\/p\u003E\u003Cpre\u003E\u003Ccode\u003Eher-food-blog.com {\n    reverse_proxy app:3000\n}\u003C\/code\u003E\u003C\/pre\u003E\u003Cp\u003EThat's it. Caddy handles everything else. No Certbot. No cron jobs. No certificate renewal panic emails.\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003EAI advantage:\u003C\/b\u003E Surfacing options I wouldn't have compared side-by-side, explaining the trade-offs clearly, and being willing to recommend rather than just listing options neutrally.\u003C\/p\u003E\u003Ch3 style=\"text-align: left;\"\u003EDay one of actual building: where it got messy\u003C\/h3\u003E\u003Cp\u003EWith the plans written and committed, we started executing Plan 1 — scaffold the project, get the Payload admin running locally.\u003C\/p\u003E\u003Cp\u003EThis is where the AI went from \"impressive planner\" to \"frustrating sometimes, but honest about it.\"\u003C\/p\u003E\u003Ch4 style=\"text-align: left;\"\u003EFlop #1: The TTY problem\u003C\/h4\u003E\u003Cp\u003EThe first real task is running `create-payload-app` to scaffold the Next.js + Payload project. Claude tried to run it:\u003C\/p\u003E\u003Cpre\u003E\u003Ccode\u003Ecd \/tmp \u0026amp;\u0026amp; npx create-payload-app@latest mlob-scaffold --template blank --db postgres --no-deps-install\u003C\/code\u003E\u003C\/pre\u003E\u003Cp\u003EOutput:\u003C\/p\u003E\u003Cpre\u003E\u003Ccode\u003E■  TTY initialization failed: uv_tty_init returned EINVAL (invalid argument)\u003C\/code\u003E\u003C\/pre\u003E\u003Cp\u003EThe CLI needs an interactive terminal to display its setup wizard. The AI's Bash tool doesn't have one — it runs commands in a non-interactive shell. Claude caught this immediately: \"I can't run this one. You'll need to run it yourself in your terminal.\"\u003C\/p\u003E\u003Cp\u003EI ran it directly. It worked.\u003C\/p\u003E\u003Cp\u003EThis is a recurring pattern with AI-assisted development. The AI is great at running build commands, file edits, git operations, TypeScript checks. Anything that needs a real TTY — interactive CLIs, things with fancy terminal UIs — it can't touch. You just have to know which category you're in.\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003EAI flop:\u003C\/b\u003E Silently failing on interactive CLIs. Not really Claude's fault — it's a fundamental constraint of how the tool works. But it's a speed bump you hit before you know to expect it.\u003C\/p\u003E\u003Ch4 style=\"text-align: left;\"\u003EFlop #2: The blank password problem\u003C\/h4\u003E\u003Cp\u003EOnce the scaffold was copied into the repo, we needed to start the PostgreSQL Docker container. Claude created the `docker-compose.yml` and ran:\u003C\/p\u003E\u003Cpre\u003E\u003Ccode\u003Edocker compose up -d db\u003C\/code\u003E\u003C\/pre\u003E\u003Cp\u003EWarning in the output: `The \"POSTGRES_PASSWORD\" variable is not set. Defaulting to a blank string.`\u003C\/p\u003E\u003Cp\u003EThe container started anyway... then immediately started restarting in a crash loop. Log:\u003C\/p\u003E\u003Cpre\u003E\u003Ccode\u003EError: Database is uninitialized and superuser password is not specified.\nYou must specify POSTGRES_PASSWORD to a non-empty value for the superuser.\u003C\/code\u003E\u003C\/pre\u003E\u003Cp\u003EPostgreSQL refuses to start with a blank password. Fair enough. The problem was that we'd run `docker compose up` *before* creating the `.env` file.\u003C\/p\u003E\u003Cp\u003EHere's the thing that took a minute to debug: Docker Compose reads a `.env` file for variable substitution. Next.js reads `.env.local`. They're not the same file, and one does not substitute for the other. Claude had planned to create `.env.local` with the database credentials. What it had missed — and what wasn't in the original plan — is that `docker compose` needs its own `.env` file with `POSTGRES_PASSWORD` set.\u003C\/p\u003E\u003Cp\u003ESo now we have two env files:\u003C\/p\u003E\u003Cp\u003E- `.env` — just `POSTGRES_PASSWORD=mlob-dev-password` (for Docker Compose)\u003C\/p\u003E\u003Cp\u003E- `.env.local` — everything Next.js needs (DATABASE_URI, PAYLOAD_SECRET, etc.)\u003C\/p\u003E\u003Cp\u003EStop the container, delete the freshly-created volume (it was initialised with a blank password so it's poisoned), create both files, restart. Container came up healthy.\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003EAI flop:\u003C\/b\u003E Missing the two-env-file requirement in the original plan. Not wrong, exactly — Next.js projects commonly use only `.env.local`. But the interaction with Docker Compose's variable substitution was a gap. Claude caught it during debugging and documented it immediately so it won't happen again.\u003C\/p\u003E\u003Ch4 style=\"text-align: left;\"\u003EFlop #3: The misnamed environment variable\u003C\/h4\u003E\u003Cp\u003EContainer healthy, `.env.local` created, `npm run dev` running, browser open at `http:\/\/localhost:3000\/admin`.\u003C\/p\u003E\u003Cpre\u003E\u003Ccode\u003E500 Internal Server Error\u003C\/code\u003E\u003C\/pre\u003E\u003Cp\u003EDev server log:\u003C\/p\u003E\u003Cpre\u003E\u003Ccode\u003EError: cannot connect to Postgres. Details: SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string\u003C\/code\u003E\u003C\/pre\u003E\u003Cp\u003E\"Client password must be a string\" means the password reached the database driver as `undefined` — not a wrong password, but *no* password.\u003C\/p\u003E\u003Cp\u003EThe `payload.config.ts` from the scaffold read `process.env.DATABASE_URL`. Our `.env.local` had `DATABASE_URI`. One letter difference: `URL` vs `URI`.\u003C\/p\u003E\u003Cp\u003EThis is a maddening class of bug. The application starts fine, no startup errors, no warnings about missing environment variables — it just silently gets `undefined` from `process.env.DATABASE_URL` and tries to connect with that. The error only surfaces when the first database query runs.\u003C\/p\u003E\u003Cp\u003EThe fix was one word:\u003C\/p\u003E\u003Cpre\u003E\u003Ccode\u003E\/\/ Before (scaffold default)\nconnectionString: process.env.DATABASE_URL || '',\n\n\/\/ After (matches our spec)\nconnectionString: process.env.DATABASE_URI || '',\u003C\/code\u003E\u003C\/pre\u003E\u003Cp\u003ERestart the dev server. `\/admin` returned 200.\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003EAI flop:\u003C\/b\u003E The scaffold used `DATABASE_URL`; our plan specified `DATABASE_URI`. Claude generated the `.env.local` with `DATABASE_URI` (correct per our spec) without noticing the payload.config.ts disagreed. This is a subtle cross-file consistency issue — the kind that's easy to miss in a first-pass implementation and annoying to debug because nothing warns you.\u003C\/p\u003E\u003Cp\u003EThe positive: once the error appeared, Claude diagnosed it correctly within one look at the logs and the config file. The debugging was fast even if the bug shouldn't have existed.\u003C\/p\u003E\u003Ch3 style=\"text-align: left;\"\u003EThe win: Payload admin is live\u003C\/h3\u003E\u003Cp\u003EAfter fixing the env variable, `http:\/\/localhost:3000\/admin` loaded. Payload's first-run screen — create your admin user, set a password, log in. The admin dashboard appeared.\u003C\/p\u003E\u003Cp\u003EIt's a bare scaffold at this point. Two collections (Users and Media), nothing else. But the plumbing works: Next.js talking to Payload, Payload talking to PostgreSQL in Docker, all running locally, all wired together correctly.\u003C\/p\u003E\u003Cp\u003EFirst commit of actual code: `feat: scaffold Next.js 15 + Payload CMS v3 project`. 26 files changed.\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003EAI advantage: \u003C\/b\u003EThe systematic approach to debugging. When `\/admin` returned 500, Claude didn't guess — it read the log, identified the exact error message, pulled up the relevant config file, and found the mismatch. The turnaround from \"500 error\" to \"fix committed\" was probably 10 minutes including the docker restart.\u003C\/p\u003E\u003Ch3 style=\"text-align: left;\"\u003EEnd of day: what's done, what's next\u003C\/h3\u003E\u003Cp\u003E\u003Cb\u003EDone:\u003C\/b\u003E\u003C\/p\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003EFull architecture and implementation plans documented\u003C\/li\u003E\u003Cli\u003ENext.js 15 + Payload CMS v3 scaffold committed\u003C\/li\u003E\u003Cli\u003EPostgreSQL running in Docker\u003C\/li\u003E\u003Cli\u003EPayload admin accessible at `\/admin`\u003C\/li\u003E\u003Cli\u003EDesign tokens, gitignore, env file setup all sorted\u003C\/li\u003E\u003C\/ul\u003E\u003Cp\u003E\u003Cb\u003ENot done:\u003C\/b\u003E\u003C\/p\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003ETailwind CSS (next session: Task 2)\u003C\/li\u003E\u003Cli\u003EFonts\u003C\/li\u003E\u003Cli\u003EAny actual Payload collections for the real content (recipes, categories, workshops)\u003C\/li\u003E\u003Cli\u003EPages — zero public pages exist yet\u003C\/li\u003E\u003C\/ul\u003E\u003Ch3 style=\"text-align: left;\"\u003EHonest take on day one\u003C\/h3\u003E\u003Cp\u003EThe AI was genuinely useful. The planning phase was better with Claude than it would have been alone — more thorough, faster, better documented. The debugging was efficient. The code it wrote was clean.\u003C\/p\u003E\u003Cp\u003EBut it makes mistakes. The two-env-file gap and the DATABASE_URL\/URI mismatch are both things a careful human would have caught in a review. The TTY limitation is a real friction point for CLIs.\u003C\/p\u003E\u003Cp\u003EThe pattern I'm settling into, and what I'll do differently going forward:\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003EDetailed plans before any code.\u003C\/b\u003E The superpowers planning workflow — structured implementation plans committed to disk before touching the codebase — is the highest-leverage thing in this setup. Not because Claude can't improvise, but because plans on disk survive context compaction. When a new session starts and the first thing Claude reads is the plan, decisions from three sessions ago are still there.\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003EMemory between sessions.\u003C\/b\u003E At the end of each session, ask Claude to save key decisions, gotchas, and context to memory files. That covers what plans don't — the \"we renamed DATABASE_URL to DATABASE_URI and here's why\" kind of context. Start each new session fresh; the memory carries what matters.\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003ESmall chunks, always verify.\u003C\/b\u003E One task. One commit. One check before moving on. I ask verification questions at every step — what does the log actually say, what's the exact env var name, does this match what we planned. It slows the session down. It also catches the DATABASE_URL\/URI class of bug before it becomes a midnight debugging session.\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003EThe AI isn't unreliable \u003C\/b\u003E— it's stateless. Build your workflow around that fact and it's a genuinely useful collaborator. Don't and you'll spend half your time re-explaining decisions you already made.\u003C\/p\u003E\u003Cp\u003E\u003Cb\u003EDay2:\u003C\/b\u003E Tailwind CSS v4 with design tokens, fonts, and the first real Payload collections.\u003C\/p\u003E\u003Cp\u003E\u003Ci\u003EThis is part of an ongoing series documenting the rebuild of a friend's food blog from WordPress to a custom Next.js stack, built with AI assistance.\u003C\/i\u003E\u003C\/p\u003E\u003Cp\u003E\u0026nbsp;\u003C\/p\u003E\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/7069907650143637205\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/05\/day-01-tearing-down-wordpress-house.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/7069907650143637205"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/7069907650143637205"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2026\/05\/day-01-tearing-down-wordpress-house.html","title":"Day 01 — Tearing Down the WordPress House"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhSWU3SJwC_AdP363kGd_DEjnXiArI-590wypUX0vPx4707EcUqR7_Y8giXG_Jpp2cn4TRqnuPCEpdRaX_g-yZVYL_aTbep84n3JUhLpyXZknyjFZrRIxtMUCk62TODZSCeX9MkRYMMbVyDITHyZhUj3tEUCQHUsfYc7vIkOclTXSwhIMrhQNEXLtzBftU\/s72-c\/c5592c31-c7a3-480a-a99f-7af1f12437aa.png","height":"72","width":"72"},"thr$total":{"$t":"0"},"georss$featurename":{"$t":"Seattle, WA, USA"},"georss$point":{"$t":"47.6061389 -122.3328481"},"georss$box":{"$t":"19.295905063821152 -157.4890981 75.916372736178843 -87.1765981"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-6720892550572492297"},"published":{"$t":"2025-10-30T08:27:00.000-07:00"},"updated":{"$t":"2026-05-25T18:05:53.886-07:00"},"category":[{"scheme":"http://www.blogger.com/atom/ns#","term":"featured"},{"scheme":"http://www.blogger.com/atom/ns#","term":"VibeEngineering"}],"title":{"type":"text","$t":"Can GPT5 \/ Claude Sonet replace me and my team ?"},"content":{"type":"html","$t":"\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEiyU4lfdaGf_e_QtEJwdo73KsC1RdNnLeugTDeEWUveWJfwtn9fFhk-QxdJfBQaUELLaFsVTXeS2iiGzPcsI8pQUPpxD_KxMQYFOrtNHl2x9eP3JRdndKNQGauxRoDD_a-M9UvdAYwSTCtr9pYvPN32ZIyIT_-fbOgBSFA6OFPgXdjTAF2H4tF3SmSZbew\/s1024\/IMG_0019.png\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1024\" data-original-width=\"1024\" height=\"320\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEiyU4lfdaGf_e_QtEJwdo73KsC1RdNnLeugTDeEWUveWJfwtn9fFhk-QxdJfBQaUELLaFsVTXeS2iiGzPcsI8pQUPpxD_KxMQYFOrtNHl2x9eP3JRdndKNQGauxRoDD_a-M9UvdAYwSTCtr9pYvPN32ZIyIT_-fbOgBSFA6OFPgXdjTAF2H4tF3SmSZbew\/s320\/IMG_0019.png\" width=\"320\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003ECan AI Agents replace my team including myself ?\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ESeems to be the question on everyones mind lately. Well can they really ?. Let's find out shall we ?\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ESpoiler alert, the answer is not a simple YES or NO.\u0026nbsp;\u003C\/div\u003E\u003Ch3 style=\"text-align: left;\"\u003EVibe Coding\u003C\/h3\u003E\u003Cdiv style=\"text-align: left;\"\u003ELets gets some things clarified. I'm not vibe coding here. I have strong opinions about the pitfalls of vibe coding in production systems. This is not the place to discuss that. I don't exactly know what to call this but to put things simply I'm using AI with well defined context to do this experiment.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cdiv\u003E\u003Ch3 style=\"text-align: left;\"\u003ECredits\u003C\/h3\u003E\u003C\/div\u003E\u003Cdiv\u003EThis project is based on\u0026nbsp;\u003Ca href=\"https:\/\/www.linkedin.com\/in\/eddonner\/\" rel=\"nofollow\" target=\"_blank\"\u003EEd Donner's\u003C\/a\u003E\u0026nbsp;CrewAI project featured in\u0026nbsp;\u003Ca href=\"https:\/\/www.udemy.com\/course\/the-complete-agentic-ai-engineering-course\/?couponCode=MT251028G17\" rel=\"nofollow\" target=\"_blank\"\u003EThe Complete AI Engineering Course\u003C\/a\u003E, If you are interested in learning more you should check out the\u0026nbsp;\u003Ca href=\"https:\/\/www.udemy.com\/courses\/search\/?src=ukw\u0026amp;q=ed+donner\" rel=\"nofollow\" target=\"_blank\"\u003Elearning material\u003C\/a\u003E\u0026nbsp;by\u0026nbsp;\u003Ca href=\"https:\/\/www.linkedin.com\/in\/eddonner\/\" rel=\"nofollow\" target=\"_blank\"\u003EEd Donner\u003C\/a\u003E. I highly recommend them.\u0026nbsp;\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Ch3\u003EThe Project\u003C\/h3\u003E\u003Cdiv\u003EIn order to test out this hypothesis I'm gonna use\u0026nbsp;\u003Ca href=\"https:\/\/www.crewai.com\/\" rel=\"nofollow\" target=\"_blank\"\u003ECrew AI\u0026nbsp;\u003C\/a\u003E. The title was a catchy one, the project however has a narrower context. I'm not trying to create an AI Engineering team to replace us humans, rather experimenting to see how we can use AI to boost our productivity.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EThe problem I have chosen, I believe is not too complex, not too simple. This will allow me to analyze the results easily and get a good baseline for further experimentation.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EThe task is to get a containerized solution up and running to a provided problem statement. Which is classified as an \"assignment\" in the below config. We'll go into details when analyzing the two different approaches.\u0026nbsp;\u003C\/div\u003E\u003Ch4 style=\"text-align: left;\"\u003E\n\n    Task Definitions\u003C\/h4\u003E\u003Cpre\u003E\u003Ccode\u003Edesign_task:\n  description: \u0026gt;\n    Create a solution determining what docker images can be used to solve the {assignment}\n    Follow docker and docker compose best practices\n    IMPORTANT: Output ONLY the raw markdown without any markdown formatting, code block delimiters, or backticks.\n    the current year is {current_year}.\n  expected_output: \u0026gt;\n    A docker compose file to do the {assignment}\n  agent: senior_engineer\n  output_file: output\/design.md\n\ncoding_task:\n  description: \u0026gt;\n    Create a docker compose file that implements the design described by senior_engineer, in order to achieve the requirements.\n    Here are the requirements: {assignment}\n    Follow docker and docker compose best practices\n    IMPORTANT: Output ONLY the raw docker compose code without any markdown formatting, code block delimiters, or backticks.\n    the current year is {current_year}.\n  expected_output: \u0026gt;\n    A docker compose file to do the {assignment}\n  agent: docker_engineer\n  context:\n    - design_task\n  output_file: output\/docker-compose.yml\n\ndocumentation_task:\n  description: \u0026gt;\n    Create a readme.md markdown file explaining the docker compose file and how to run it\n    IMPORTANT: Output ONLY the raw markdown code without any markdown formatting, code block delimiters, or backticks.\n    the current year is {current_year}.\n  expected_output: \u0026gt;\n    A readme.md file explaining the solution and how to run it\n  agent: docker_engineer\n  context:\n    - coding_task\n  output_file: output\/readme.md    \u003C\/code\u003E\u003C\/pre\u003E\n  \n\u003C\/div\u003E\u003Ch4 style=\"text-align: left;\"\u003EGive me the details, gimme, gimme\u003C\/h4\u003E\u003Cdiv style=\"text-align: left;\"\u003EI try two different approaches in this experiment. The first approach is to use a human senior engineer with an AI engineer to build the system. The human senior engineer is going to provide very prescriptive instructions for the AI engineer to complete the task in hand.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003EIn the second approach we are going to use two different AI agents. One senior engineer which we will equip with design capabilities. We are gonna provide this agent with a problem statement with some guidelines. Then this senior AI agent is going to create the prescriptive instructions that will be used by the same AI engineer from the pervious\u0026nbsp; example to produce the code.\u0026nbsp;\u003C\/div\u003E\u003Ch4 style=\"text-align: left;\"\u003EModels Used\u003C\/h4\u003E\u003Cdiv style=\"text-align: left;\"\u003EI tried using different models to try out this concept\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003EClaude Sonet 4 (claude-sonnet-4-20250514) by Anthropic\u003C\/li\u003E\u003Cli\u003Egpt-4.1 by OpenAI\u003C\/li\u003E\u003Cli\u003Egpt5 by OpenAI\u003C\/li\u003E\u003Cli\u003Edeepseek-chat by DeepSeek\u003C\/li\u003E\u003Cli\u003Ellama3.2 by Meta hosted with Ollama locally\u003C\/li\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003Ch4 style=\"text-align: left;\"\u003EHuman Senior Engineer with an AI Engineering team\u003C\/h4\u003E\u003Cdiv style=\"text-align: left;\"\u003EIn the first approach we have a human senior engineer providing the detailed instructions to the AI engineering team. The senior engineer is going to provide a very prescriptive task for the AI engineers\u003C\/div\u003E\u003Cpre\u003E\u003Ccode\u003Eassignment = f\"\"\"Created a self hosted nextcloud instance. Use the docker hub image nextcloud:31.0.8-apache\nuse mariadb:10.11 as the database \nuse redis:alpine3.22 with nextcloud\ncreate separate containers for nextcloud and cron jobs\nUse jc21\/nginx-proxy-manager:latest as a reverse proxy to expose the nextcloud instance to the internet\n# \"\"\"\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Ch4 style=\"text-align: left;\"\u003EAI Senior Engineer with an AI Engineering Team\u003C\/h4\u003E\u003Cdiv style=\"text-align: left;\"\u003EIn the second approach the senior engineer is also replaced by an AI Agent. We provide the problem statement to the AI Senior Engineering Agent. This agent in turn is gonna provide a prescriptive task for the crew to generate the output\u003C\/div\u003E\u003Cpre\u003E\u003Ccode\u003Eassignment = f\"\"\"Created a self hosted nextcloud instance. \nThink about separation of concerns, maintainability and other software development best practices\nThe instance needs to be exposed to the internet\nThe same server that is going to host the nextcloud instance also has some simple websites exposed to the internet\nThe nextcloud instance and the other websites need their own ssl certificates \nThe server hosting all these is behind a pfsense firewall\n\"\"\"\n\u003C\/code\u003E\u003C\/pre\u003E\u003Ch4 style=\"text-align: left;\"\u003EAgent Definitions\u003C\/h4\u003E\u003Cpre\u003E\u003Ccode\u003Esenior_engineer:\n  role: \u0026gt;\n    Engineer with extensive knowledge about software development practices including web based security vulnerabilities\n  goal: \u0026gt;\n    Provide solutions for complex software problems\n  backstory: \u0026gt;\n    You're a tenured engineer with extensive knowledge about software engineering\n    You are able to create software designs that can guide other engineers in creating solutions\n  llm: anthropic\/claude-sonnet-4-20250514\n\ndocker_engineer:\n  role: \u0026gt;\n    Engineer with extensive knowledge about docker and docker compose\n  goal: \u0026gt;\n    Generate docker compose files with explanations\n  backstory: \u0026gt;\n    You're a tenured engineer with extensive knowledge about docker and docker compose. \n    You are able to create working docker compose files following best practices to fulfill the given requirements.\n  llm: anthropic\/claude-sonnet-4-20250514\u003C\/code\u003E\u003C\/pre\u003E\u003Ch3 style=\"text-align: left;\"\u003EOutput and Conclusions\u003C\/h3\u003E\u003Cdiv style=\"text-align: left;\"\u003ETo be honest this exercise is too narrow and simplistic to come to a concrete conclusion. It does show some good data points though. (Full outputs in appendix)\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003EWhen it comes to agents I was not able to produce good results with deepseek-chat or llama3.2. Only the frontier models were able to provide good enough data points in this project.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003EThe first approach created a good simple working solution sticking with the provided guidelines. The second approach created a novel solution which could be classified as overkill. Though on second thought narrowing down the requirements to specify the host environment would have generated better results.\u0026nbsp;\u003C\/div\u003E\u003Ch4 style=\"text-align: left;\"\u003ESome interesting observations include,\u0026nbsp;\u003C\/h4\u003E\u003Cdiv\u003EWhen acting as the Senior Engineer GPT 4.1 choose to use NGINX as the proxy where as GPT5 choose to use Traefik. According to Gemini, Traefik is more suitable for dynamic environments like Docker and Kubernetes. For the simple task definition NGINX would have been acceptable but I can't argue with using Traefik either. The problem statement didn't have enough information about the future extensibility or the available resourcing to lean towards one vs the other.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EAs the senior engineer GPT5 choose to use Postgress for the database where GPT 4.1 used MariaDB. The decision between these 2 also is a hard one. If you are curious\u0026nbsp;\u003Ca href=\"https:\/\/share.google\/aimode\/V2y8KJuXSQmluMyeI\" rel=\"nofollow\" target=\"_blank\"\u003Eread more here\u003C\/a\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EIn my opinion, both these decision differences indicate GPT5 chose the more modern options than GPT 4.1 in this particular problem. It could simply be due to the training data.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EClaude Sonet 4 failed to act as the senior engineer for my project. It kept giving me \"{type:error,error:{type:overloaded_error,message:Overloaded},request_id:null}\". I see this error often when I try to do tasks needing more tokens. This I believe is due to the basic plan I'm paying for, not due to the capabilities of the Model.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EDeepSeek-Chat hallucinated and returned a template project. Llama 3.2 failed as well. Indicating I'm trying to do too much with them.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EWhen working with a human senior engineer the results were very similar across all frontier models.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ECheck out the appendix section for detailed outputs.\u0026nbsp;\u003C\/div\u003E\u003Ch4 style=\"text-align: left;\"\u003EConclusion\u003C\/h4\u003E\u003Cdiv\u003EWell, AI has been a force multiplier when it comes to increasing my productivity. I'm not sold on the fact that AI will be able to completely replace us, however it sure is allowing us to do more with less. How that will pan out, only time will tell.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EI'm excited for the future. What mistakes we will create vibe coding, what we will learn from those mistakes. Potential innovations we will see with responsible AI usage.... etc... etc...\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Ch3 style=\"text-align: left;\"\u003EAppendix\u0026nbsp;\u003C\/h3\u003E\n\u003Ch4 style=\"text-align: left;\"\u003EGPT 4.1 output (ai senior engineer)\u003C\/h4\u003E\n\u003Cpre\u003E\u003Ccode\u003E\nversion: \"3.9\"\n\nservices:\n  nginx-proxy-manager:\n    image: jc21\/nginx-proxy-manager:2.11.2\n    container_name: nginx-proxy-manager\n    restart: unless-stopped\n    ports:\n      - \"80:80\"\n      - \"443:443\"\n      - \"81:81\"\n    volumes:\n      - npm_data:\/data\n      - npm_letsencrypt:\/etc\/letsencrypt\n    environment:\n      - TZ=UTC\n    networks:\n      - frontend\n\n  db:\n    image: mariadb:11\n    container_name: nextcloud-db\n    restart: unless-stopped\n    command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW\n    environment:\n      - MYSQL_ROOT_PASSWORD=secure_root_password\n      - MYSQL_DATABASE=nextcloud\n      - MYSQL_USER=nextcloud\n      - MYSQL_PASSWORD=secure_nextcloud_password\n    volumes:\n      - db_data:\/var\/lib\/mysql\n    networks:\n      - backend\n\n  nextcloud:\n    image: nextcloud:28\n    container_name: nextcloud-app\n    restart: unless-stopped\n    expose:\n      - \"80\"\n    environment:\n      - MYSQL_PASSWORD=secure_nextcloud_password\n      - MYSQL_DATABASE=nextcloud\n      - MYSQL_USER=nextcloud\n      - MYSQL_HOST=db\n    volumes:\n      - nextcloud_data:\/var\/www\/html\n    depends_on:\n      - db\n    networks:\n      - frontend\n      - backend\n\n  web1:\n    image: nginx:1.25\n    container_name: simple-web1\n    restart: unless-stopped\n    volumes:\n      - .\/web1\/html:\/usr\/share\/nginx\/html:ro\n      - .\/web1\/nginx.conf:\/etc\/nginx\/nginx.conf:ro\n    expose:\n      - \"80\"\n    networks:\n      - frontend\n\n  web2:\n    image: nginx:1.25\n    container_name: simple-web2\n    restart: unless-stopped\n    volumes:\n      - .\/web2\/html:\/usr\/share\/nginx\/html:ro\n      - .\/web2\/nginx.conf:\/etc\/nginx\/nginx.conf:ro\n    expose:\n      - \"80\"\n    networks:\n      - frontend\n\nvolumes:\n  npm_data:\n  npm_letsencrypt:\n  db_data:\n  nextcloud_data:\n\nnetworks:\n  frontend:\n    driver: bridge\n  backend:\n    driver: bridge\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Ch4 style=\"text-align: left;\"\u003EGPT 5 output (ai senior engineer)\u003C\/h4\u003E\n\u003Cpre\u003E\u003Ccode\u003E\nname: selfhosted-apps\n\n# Compose Specification (no version field needed in modern Compose)\n# Requirements before use:\n# - Create a .env file with:\n#     TRAEFIK_ACME_EMAIL=you@example.com\n#     NEXTCLOUD_DOMAIN=cloud.example.com\n#     SITE1_DOMAIN=site1.example.com\n#     SITE2_DOMAIN=site2.example.com\n# - Create secrets\/nextcloud_db_password.txt with a strong DB password\n# - On pfSense: port-forward TCP 80 and 443 to this Docker host\n# - Ensure DNS A\/AAAA records for all domains point to your public IP\n\nservices:\n  # Reverse proxy and TLS termination with automatic per-domain certificates\n  traefik:\n    image: traefik:v3.1\n    command:\n      - --providers.docker=true\n      - --providers.docker.endpoint=tcp:\/\/docker-socket-proxy:2375\n      - --providers.docker.exposedbydefault=false\n      - --providers.docker.network=proxy\n      - --entrypoints.web.address=:80\n      - --entrypoints.web.http.redirections.entryPoint.to=websecure\n      - --entrypoints.web.http.redirections.entryPoint.scheme=https\n      - --entrypoints.websecure.address=:443\n      - --certificatesresolvers.letsencrypt.acme.email=${TRAEFIK_ACME_EMAIL}\n      - --certificatesresolvers.letsencrypt.acme.storage=\/letsencrypt\/acme.json\n      - --certificatesresolvers.letsencrypt.acme.httpchallenge=true\n      - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web\n      - --log.level=INFO\n      - --accesslog=true\n    ports:\n      - 80:80\n      - 443:443\n    volumes:\n      - traefik_letsencrypt:\/letsencrypt\n    networks:\n      - proxy\n      - dockerproxy\n    depends_on:\n      - docker-socket-proxy\n    # Run as non-root with capability to bind low ports\n    user: \"65532:65532\"\n    cap_add:\n      - NET_BIND_SERVICE\n    security_opt:\n      - no-new-privileges:true\n    restart: unless-stopped\n    labels:\n      - traefik.enable=true\n      # Shared middlewares for security and compression\n      - traefik.http.middlewares.secure-headers.headers.stsSeconds=31536000\n      - traefik.http.middlewares.secure-headers.headers.stsIncludeSubdomains=true\n      - traefik.http.middlewares.secure-headers.headers.stsPreload=true\n      - traefik.http.middlewares.secure-headers.headers.referrerPolicy=no-referrer\n      - traefik.http.middlewares.secure-headers.headers.browserXssFilter=true\n      - traefik.http.middlewares.secure-headers.headers.contentTypeNosniff=true\n      - traefik.http.middlewares.compress.compress=true\n\n  # Safer Docker socket access for Traefik (principle of least privilege)\n  docker-socket-proxy:\n    image: tecnativa\/docker-socket-proxy:latest\n    environment:\n      - CONTAINERS=1\n      - SERVICES=1\n      - TASKS=1\n      - NETWORKS=1\n      - NODES=1\n      - INFO=1\n      - PING=1\n      - VERSION=1\n      - EVENTS=1\n    volumes:\n      - \/var\/run\/docker.sock:\/var\/run\/docker.sock:ro\n    expose:\n      - \"2375\"\n    networks:\n      - dockerproxy\n    read_only: true\n    security_opt:\n      - no-new-privileges:true\n    restart: unless-stopped\n\n  # Nextcloud application (Apache variant)\n  nextcloud:\n    image: nextcloud:29-apache # pin to a stable major\/minor; update intentionally\n    depends_on:\n      db:\n        condition: service_healthy\n      redis:\n        condition: service_started\n    environment:\n      - POSTGRES_HOST=db\n      - POSTGRES_DB=nextcloud\n      - POSTGRES_USER=nextcloud\n      - POSTGRES_PASSWORD_FILE=\/run\/secrets\/nextcloud_db_password\n      - REDIS_HOST=redis\n      - NEXTCLOUD_TRUSTED_DOMAINS=${NEXTCLOUD_DOMAIN}\n      - TRUSTED_PROXIES=traefik\n      - OVERWRITEPROTOCOL=https\n      - PHP_MEMORY_LIMIT=1024M\n      - PHP_UPLOAD_LIMIT=2048M\n    volumes:\n      # granular persistence for clean upgrades\n      - nextcloud_data:\/var\/www\/html\/data\n      - nextcloud_config:\/var\/www\/html\/config\n      - nextcloud_custom_apps:\/var\/www\/html\/custom_apps\n      - nextcloud_themes:\/var\/www\/html\/themes\n    labels:\n      - traefik.enable=true\n      - traefik.http.routers.nextcloud.rule=Host(`${NEXTCLOUD_DOMAIN}`)\n      - traefik.http.routers.nextcloud.entrypoints=websecure\n      - traefik.http.routers.nextcloud.tls.certresolver=letsencrypt\n      - traefik.http.services.nextcloud.loadbalancer.server.port=80\n      - traefik.http.routers.nextcloud.middlewares=secure-headers@docker,nextcloud-buffering\n      # Allow large uploads and streaming\n      - traefik.http.middlewares.nextcloud-buffering.buffering.maxRequestBodyBytes=0\n      - traefik.http.middlewares.nextcloud-buffering.buffering.memRequestBodyBytes=8388608\n      - traefik.http.middlewares.nextcloud-buffering.buffering.maxResponseBodyBytes=0\n    networks:\n      - proxy\n      - nextcloud\n    secrets:\n      - nextcloud_db_password\n    healthcheck:\n      test: [\"CMD-SHELL\", \"curl -fsS http:\/\/localhost\/status.php || exit 1\"]\n      interval: 30s\n      timeout: 10s\n      retries: 10\n    restart: unless-stopped\n\n  # Nextcloud cron (runs background jobs every 5 minutes)\n  nextcloud-cron:\n    image: nextcloud:29-apache\n    entrypoint: \/cron.sh\n    depends_on:\n      - nextcloud\n    volumes:\n      - nextcloud_data:\/var\/www\/html\/data\n      - nextcloud_config:\/var\/www\/html\/config\n      - nextcloud_custom_apps:\/var\/www\/html\/custom_apps\n      - nextcloud_themes:\/var\/www\/html\/themes\n    networks:\n      - nextcloud\n    restart: unless-stopped\n\n  # PostgreSQL database for Nextcloud\n  db:\n    image: postgres:16-alpine\n    environment:\n      - POSTGRES_DB=nextcloud\n      - POSTGRES_USER=nextcloud\n      - POSTGRES_PASSWORD_FILE=\/run\/secrets\/nextcloud_db_password\n    secrets:\n      - nextcloud_db_password\n    volumes:\n      - db_data:\/var\/lib\/postgresql\/data\n    networks:\n      - nextcloud\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U nextcloud -d nextcloud || exit 1\"]\n      interval: 10s\n      timeout: 5s\n      retries: 10\n    restart: unless-stopped\n\n  # Redis for file locking and caching\n  redis:\n    image: redis:7-alpine\n    command: [\"redis-server\", \"--appendonly\", \"yes\"]\n    volumes:\n      - redis_data:\/data\n    networks:\n      - nextcloud\n    healthcheck:\n      test: [\"CMD-SHELL\", \"redis-cli ping | grep -q PONG\"]\n      interval: 10s\n      timeout: 5s\n      retries: 10\n    restart: unless-stopped\n\n  # Example simple website 1 (static content)\n  site1:\n    image: nginx:1.27-alpine\n    volumes:\n      - .\/sites\/site1:\/usr\/share\/nginx\/html:ro\n    labels:\n      - traefik.enable=true\n      - traefik.http.routers.site1.rule=Host(`${SITE1_DOMAIN}`)\n      - traefik.http.routers.site1.entrypoints=websecure\n      - traefik.http.routers.site1.tls.certresolver=letsencrypt\n      - traefik.http.services.site1.loadbalancer.server.port=80\n      - traefik.http.routers.site1.middlewares=secure-headers@docker,compress@docker\n    networks:\n      - proxy\n    read_only: true\n    tmpfs:\n      - \/var\/cache\/nginx\n      - \/var\/run\n    restart: unless-stopped\n\n  # Example simple website 2 (static content)\n  site2:\n    image: nginx:1.27-alpine\n    volumes:\n      - .\/sites\/site2:\/usr\/share\/nginx\/html:ro\n    labels:\n      - traefik.enable=true\n      - traefik.http.routers.site2.rule=Host(`${SITE2_DOMAIN}`)\n      - traefik.http.routers.site2.entrypoints=websecure\n      - traefik.http.routers.site2.tls.certresolver=letsencrypt\n      - traefik.http.services.site2.loadbalancer.server.port=80\n      - traefik.http.routers.site2.middlewares=secure-headers@docker,compress@docker\n    networks:\n      - proxy\n    read_only: true\n    tmpfs:\n      - \/var\/cache\/nginx\n      - \/var\/run\n    restart: unless-stopped\n\nsecrets:\n  nextcloud_db_password:\n    file: .\/secrets\/nextcloud_db_password.txt\n\nvolumes:\n  traefik_letsencrypt:\n  nextcloud_data:\n  nextcloud_config:\n  nextcloud_custom_apps:\n  nextcloud_themes:\n  db_data:\n  redis_data:\n\nnetworks:\n  proxy:\n    driver: bridge\n  nextcloud:\n    driver: bridge\n  dockerproxy:\n    driver: bridge\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Ch4 style=\"text-align: left;\"\u003EClaude Sonet 4 (human senior engineer)\u003C\/h4\u003E\n\u003Cpre\u003E\u003Ccode\u003E\nversion: '3.8'\n\nservices:\n  # Database service\n  db:\n    image: mariadb:10.11\n    container_name: nextcloud_db\n    restart: unless-stopped\n    environment:\n      - MYSQL_ROOT_PASSWORD=nextcloud_root_password\n      - MYSQL_DATABASE=nextcloud\n      - MYSQL_USER=nextcloud\n      - MYSQL_PASSWORD=nextcloud_password\n      - MARIADB_AUTO_UPGRADE=1\n      - MARIADB_DISABLE_UPGRADE_BACKUP=1\n    volumes:\n      - db_data:\/var\/lib\/mysql\n    networks:\n      - nextcloud_network\n    command: --transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW\n    healthcheck:\n      test: [\"CMD\", \"healthcheck.sh\", \"--connect\", \"--innodb_initialized\"]\n      start_period: 1m\n      start_interval: 10s\n      interval: 1m\n      timeout: 5s\n      retries: 3\n\n  # Redis cache service\n  redis:\n    image: redis:alpine3.22\n    container_name: nextcloud_redis\n    restart: unless-stopped\n    networks:\n      - nextcloud_network\n    command: redis-server --requirepass redis_password\n    healthcheck:\n      test: [\"CMD\", \"redis-cli\", \"--raw\", \"incr\", \"ping\"]\n      interval: 30s\n      timeout: 3s\n      retries: 3\n      start_period: 30s\n\n  # Nextcloud application\n  nextcloud:\n    image: nextcloud:31.0.8-apache\n    container_name: nextcloud_app\n    restart: unless-stopped\n    depends_on:\n      db:\n        condition: service_healthy\n      redis:\n        condition: service_healthy\n    environment:\n      - MYSQL_HOST=db\n      - MYSQL_DATABASE=nextcloud\n      - MYSQL_USER=nextcloud\n      - MYSQL_PASSWORD=nextcloud_password\n      - REDIS_HOST=redis\n      - REDIS_HOST_PASSWORD=redis_password\n      - NEXTCLOUD_ADMIN_USER=admin\n      - NEXTCLOUD_ADMIN_PASSWORD=admin_password\n      - TRUSTED_PROXIES=nginx-proxy-manager\n      - OVERWRITEPROTOCOL=https\n      - OVERWRITECLIURL=https:\/\/your-domain.com\n      - APACHE_DISABLE_REWRITE_IP=1\n    volumes:\n      - nextcloud_data:\/var\/www\/html\n      - nextcloud_custom_apps:\/var\/www\/html\/custom_apps\n      - nextcloud_config:\/var\/www\/html\/config\n      - nextcloud_themes:\/var\/www\/html\/themes\n    networks:\n      - nextcloud_network\n      - proxy_network\n    healthcheck:\n      test: [\"CMD\", \"curl\", \"-f\", \"http:\/\/localhost\/status.php\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n      start_period: 60s\n\n  # Nextcloud cron service\n  nextcloud_cron:\n    image: nextcloud:31.0.8-apache\n    container_name: nextcloud_cron\n    restart: unless-stopped\n    depends_on:\n      - nextcloud\n    environment:\n      - MYSQL_HOST=db\n      - MYSQL_DATABASE=nextcloud\n      - MYSQL_USER=nextcloud\n      - MYSQL_PASSWORD=nextcloud_password\n      - REDIS_HOST=redis\n      - REDIS_HOST_PASSWORD=redis_password\n    volumes:\n      - nextcloud_data:\/var\/www\/html\n      - nextcloud_custom_apps:\/var\/www\/html\/custom_apps\n      - nextcloud_config:\/var\/www\/html\/config\n      - nextcloud_themes:\/var\/www\/html\/themes\n    networks:\n      - nextcloud_network\n    entrypoint: \/cron.sh\n    user: www-data\n\n  # Nginx Proxy Manager\n  nginx-proxy-manager:\n    image: jc21\/nginx-proxy-manager:latest\n    container_name: nginx_proxy_manager\n    restart: unless-stopped\n    ports:\n      - \"80:80\"\n      - \"443:443\"\n      - \"81:81\"\n    environment:\n      - DB_SQLITE_FILE=\/data\/database.sqlite\n    volumes:\n      - npm_data:\/data\n      - npm_letsencrypt:\/etc\/letsencrypt\n    networks:\n      - proxy_network\n    healthcheck:\n      test: [\"CMD\", \"wget\", \"--no-verbose\", \"--tries=1\", \"--spider\", \"http:\/\/localhost:81\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n      start_period: 60s\n\nvolumes:\n  db_data:\n    driver: local\n  nextcloud_data:\n    driver: local\n  nextcloud_custom_apps:\n    driver: local\n  nextcloud_config:\n    driver: local\n  nextcloud_themes:\n    driver: local\n  npm_data:\n    driver: local\n  npm_letsencrypt:\n    driver: local\n\nnetworks:\n  nextcloud_network:\n    driver: bridge\n    internal: true\n  proxy_network:\n    driver: bridge\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Ch4\u003EGPT 4.1 output (human senior engineer)\u003C\/h4\u003E\n\u003Cpre\u003E\u003Ccode\u003E\nversion: \"3.9\"\n\nservices:\n  nextcloud:\n    image: nextcloud:31.0.8-apache\n    restart: unless-stopped\n    depends_on:\n      - db\n      - redis\n    environment:\n      - MYSQL_HOST=db\n      - MYSQL_DATABASE=nextcloud\n      - MYSQL_USER=nextcloud\n      - MYSQL_PASSWORD=nextcloud_db_password\n      - REDIS_HOST=redis\n      - REDIS_HOST_PASSWORD=redis_password\n      - NEXTCLOUD_TRUSTED_DOMAINS=nextcloud.example.com\n    volumes:\n      - nextcloud_data:\/var\/www\/html\n    networks:\n      - nextcloud_network\n\n  nextcloud-cron:\n    image: nextcloud:31.0.8-apache\n    restart: unless-stopped\n    entrypoint: \"\/cron.sh\"\n    depends_on:\n      - db\n      - redis\n    environment:\n      - MYSQL_HOST=db\n      - MYSQL_DATABASE=nextcloud\n      - MYSQL_USER=nextcloud\n      - MYSQL_PASSWORD=nextcloud_db_password\n      - REDIS_HOST=redis\n      - REDIS_HOST_PASSWORD=redis_password\n    volumes:\n      - nextcloud_data:\/var\/www\/html\n    networks:\n      - nextcloud_network\n\n  db:\n    image: mariadb:10.11\n    restart: unless-stopped\n    environment:\n      - MYSQL_ROOT_PASSWORD=strong_root_password\n      - MYSQL_DATABASE=nextcloud\n      - MYSQL_USER=nextcloud\n      - MYSQL_PASSWORD=nextcloud_db_password\n    volumes:\n      - nextcloud_db:\/var\/lib\/mysql\n    networks:\n      - nextcloud_network\n\n  redis:\n    image: redis:alpine3.22\n    restart: unless-stopped\n    command: [\"redis-server\", \"--requirepass\", \"redis_password\"]\n    volumes:\n      - nextcloud_redis:\/data\n    networks:\n      - nextcloud_network\n\n  nginx-proxy-manager:\n    image: jc21\/nginx-proxy-manager:latest\n    restart: unless-stopped\n    ports:\n      - \"80:80\"\n      - \"81:81\"\n      - \"443:443\"\n    volumes:\n      - npm_data:\/data\n      - npm_letsencrypt:\/etc\/letsencrypt\n    networks:\n      - nextcloud_network\n\nvolumes:\n  nextcloud_data:\n  nextcloud_db:\n  nextcloud_redis:\n  npm_data:\n  npm_letsencrypt:\n\nnetworks:\n  nextcloud_network:\n    driver: bridge\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Ch4 style=\"text-align: left;\"\u003EGPT 5 output (human senior engineer)\u003C\/h4\u003E\n\u003Cpre\u003E\u003Ccode\u003E\nname: nextcloud-stack\nservices:\n  db:\n    image: mariadb:10.11\n    command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW --innodb-file-per-table=1 --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci\n    restart: unless-stopped\n    environment:\n      - MARIADB_DATABASE=nextcloud\n      - MARIADB_USER=nextcloud\n      - MARIADB_ROOT_PASSWORD_FILE=\/run\/secrets\/db_root_password\n      - MARIADB_PASSWORD_FILE=\/run\/secrets\/db_password\n    volumes:\n      - db_data:\/var\/lib\/mysql\n    secrets:\n      - db_root_password\n      - db_password\n    healthcheck:\n      test: [\"CMD-SHELL\", \"mysqladmin ping -h 127.0.0.1 -u root -p$(cat \/run\/secrets\/db_root_password) || exit 1\"]\n      interval: 10s\n      timeout: 5s\n      retries: 5\n    networks:\n      - backend\n    logging:\n      driver: json-file\n      options:\n        max-size: \"10m\"\n        max-file: \"3\"\n\n  redis:\n    image: redis:alpine3.22\n    restart: unless-stopped\n    command: [\"redis-server\", \"--save\", \"\", \"--appendonly\", \"no\"]\n    healthcheck:\n      test: [\"CMD\", \"redis-cli\", \"ping\"]\n      interval: 10s\n      timeout: 3s\n      retries: 5\n    networks:\n      - backend\n    logging:\n      driver: json-file\n      options:\n        max-size: \"10m\"\n        max-file: \"3\"\n\n  app:\n    image: nextcloud:31.0.8-apache\n    restart: unless-stopped\n    depends_on:\n      db:\n        condition: service_healthy\n      redis:\n        condition: service_healthy\n    environment:\n      - MYSQL_HOST=db\n      - MYSQL_DATABASE=nextcloud\n      - MYSQL_USER=nextcloud\n      - MYSQL_PASSWORD_FILE=\/run\/secrets\/db_password\n      - REDIS_HOST=redis\n      - OVERWRITEPROTOCOL=https\n      - APACHE_DISABLE_REWRITE_IP=1\n    volumes:\n      - nextcloud:\/var\/www\/html\n    secrets:\n      - db_password\n    networks:\n      - backend\n      - frontend\n    healthcheck:\n      test: [\"CMD-SHELL\", \"php -r \\\"exit((int)!@fopen('http:\/\/127.0.0.1\/status.php', 'r'));\\\"\"]\n      interval: 30s\n      timeout: 10s\n      retries: 10\n    logging:\n      driver: json-file\n      options:\n        max-size: \"10m\"\n        max-file: \"3\"\n\n  cron:\n    image: nextcloud:31.0.8-apache\n    restart: unless-stopped\n    depends_on:\n      app:\n        condition: service_started\n    entrypoint: \/cron.sh\n    volumes:\n      - nextcloud:\/var\/www\/html\n    networks:\n      - backend\n    logging:\n      driver: json-file\n      options:\n        max-size: \"10m\"\n        max-file: \"3\"\n\n  npm:\n    image: jc21\/nginx-proxy-manager:latest\n    restart: unless-stopped\n    ports:\n      - \"80:80\"\n      - \"81:81\"\n      - \"443:443\"\n    volumes:\n      - npm_data:\/data\n      - npm_letsencrypt:\/etc\/letsencrypt\n    networks:\n      - frontend\n    logging:\n      driver: json-file\n      options:\n        max-size: \"10m\"\n        max-file: \"3\"\n\nvolumes:\n  nextcloud:\n  db_data:\n  npm_data:\n  npm_letsencrypt:\n\nsecrets:\n  db_root_password:\n    file: .\/secrets\/db_root_password.txt\n  db_password:\n    file: .\/secrets\/db_password.txt\n\nnetworks:\n  frontend:\n    driver: bridge\n  backend:\n    driver: bridge\n    internal: true\n\u003C\/code\u003E\u003C\/pre\u003E\n\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003C\/div\u003E\n\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/6720892550572492297\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2025\/10\/can-gpt5-claude-sonet-4-replace-me-and.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/6720892550572492297"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/6720892550572492297"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2025\/10\/can-gpt5-claude-sonet-4-replace-me-and.html","title":"Can GPT5 \/ Claude Sonet replace me and my team ?"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEiyU4lfdaGf_e_QtEJwdo73KsC1RdNnLeugTDeEWUveWJfwtn9fFhk-QxdJfBQaUELLaFsVTXeS2iiGzPcsI8pQUPpxD_KxMQYFOrtNHl2x9eP3JRdndKNQGauxRoDD_a-M9UvdAYwSTCtr9pYvPN32ZIyIT_-fbOgBSFA6OFPgXdjTAF2H4tF3SmSZbew\/s72-c\/IMG_0019.png","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-6635671021202968763"},"published":{"$t":"2025-01-28T16:51:00.000-08:00"},"updated":{"$t":"2026-05-25T18:06:04.951-07:00"},"category":[{"scheme":"http://www.blogger.com/atom/ns#","term":"Backup"},{"scheme":"http://www.blogger.com/atom/ns#","term":"featured"}],"title":{"type":"text","$t":"Backups \u0026 Snapshots"},"content":{"type":"html","$t":"\u003Cbr \/\u003E\u003Cp style=\"clear: both; text-align: left;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEiktqDG5QJ4kesGRFawvehjon0jGr6Gf3Gj4sQfsuXIV1JeaTMLyoRraY5UTSUV3xgHCxWonZYS9pLHWqoCVzd_7SDi2OjczHjsUuzuKlnzqEY4-ASPnqIIxzd3O6Jt97Vb5qqnIfzlMq225uksXNz4leN1B8S8EQDx4_-PojrRxwl9ZaaTW99Z7dzb6jA\/s2048\/Gemini_Generated_Image_4rraly4rraly4rra%20(1).jpeg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"2048\" data-original-width=\"2048\" height=\"200\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEiktqDG5QJ4kesGRFawvehjon0jGr6Gf3Gj4sQfsuXIV1JeaTMLyoRraY5UTSUV3xgHCxWonZYS9pLHWqoCVzd_7SDi2OjczHjsUuzuKlnzqEY4-ASPnqIIxzd3O6Jt97Vb5qqnIfzlMq225uksXNz4leN1B8S8EQDx4_-PojrRxwl9ZaaTW99Z7dzb6jA\/w200-h200\/Gemini_Generated_Image_4rraly4rraly4rra%20(1).jpeg\" width=\"200\" \/\u003E\u003C\/a\u003E\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003EWhat if you loose all your photos, yes every single one of your photos in your phone, laptop, usb drive. Even worse imagine loosing every single bit of your digital data. What if you had to start from a fresh laptop, mobile phone. That's a pretty close explanation of what I had to go through in 2010.\u0026nbsp;\u003C\/p\u003E\u003Ch2 style=\"text-align: justify;\"\u003EOverview\u003C\/h2\u003E\u003Cp style=\"text-align: justify;\"\u003E\u0026nbsp;A few years ago, around 2010 to be exact I lost all my data due to a hard drive failure. I was able to recover some of it but to this day regret the incident. I was keeping all my data. on a single usb hard drive. I should have known better. Hindsight is 2020. Once bitten, twice shy, I have rotated between various complex, high maintenance solutions to the current compromise. In this article I will explain how I do this at present day.\u0026nbsp;\u003C\/p\u003E\u003Ch2 style=\"text-align: justify;\"\u003EBackups vs Snapshots\u003C\/h2\u003E\u003Cp style=\"text-align: justify;\"\u003EFirst lets talk about snapshots. Having backups is good but what if you need to recover a photo you deleted 2 weeks ago. What if you wanted to recover a version of a file you changed 2 days ago. This is where snapshots comes into play. The key is to find the right balance between snapshot cadence vs retention.\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003EI use\u0026nbsp;\u003Ca href=\"https:\/\/rsnapshot.org\/\" target=\"_blank\"\u003Ersnapshot\u003C\/a\u003E\u0026nbsp;to achieve this. It's available for most linux distributions. The one I use is\u0026nbsp;\u003Ca href=\"https:\/\/wiki.archlinux.org\/title\/Rsnapshot\" target=\"_blank\"\u003Ersnapshot for Arch\u003C\/a\u003E. The idea is to have a config for different snapshots. I have one for daily, weekly and monthly snapshots. Then choosing the right execution mechanism.\u0026nbsp;\u0026nbsp;\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003E\u003C\/p\u003E\u003Cul\u003E\u003Cli\u003EDaily snapshots kept for 7 days\u003C\/li\u003E\u003Cli\u003EWeekly snapshots kept for 5 weeks\u003C\/li\u003E\u003Cli\u003EMonthly shapshots kept for 12 months\u003C\/li\u003E\u003C\/ul\u003E\u003Ch2 style=\"text-align: left;\"\u003ESpace \/ Disk Capacity\u003C\/h2\u003E\u003Cdiv\u003EHaving many snapshots doesn't mean you are gonna have many duplicate copies of data. For example, having 7 daily snapshots doesn't mean you will have 7 copies of the same data.\u0026nbsp;\u003Ca href=\"https:\/\/rsnapshot.org\/\" style=\"text-align: justify;\" target=\"_blank\"\u003Ersnapshot\u003C\/a\u003E\u0026nbsp;is based on\u0026nbsp;\u003Ca href=\"https:\/\/en.wikipedia.org\/wiki\/Rsync\" target=\"_blank\"\u003Ersync\u003C\/a\u003E. It it only stores one full copy and then the deltas needed to generate the rest. If nothing changed the same source will be symlinked into all snapshots. This means the space usage is going to depend on the variation.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ELets assume you only changed one file in your backup target within the last 7 days. This means the 7 days snapshots will have only 1 duplicate file. Thus this is relatively cheap on storage.\u0026nbsp;\u003C\/div\u003E\u003Ch2 style=\"text-align: left;\"\u003ESnapshot Executions\u003C\/h2\u003E\u003Cdiv\u003EI use a\u0026nbsp;\u003Ca href=\"https:\/\/systemd.io\/\" target=\"_blank\"\u003Esystemd\u003C\/a\u003E\u0026nbsp;based timer and a \u003Ca href=\"https:\/\/man.archlinux.org\/man\/systemd.service.5.en\" target=\"_blank\"\u003Eservice\u003C\/a\u003E to fire the snapshots. You can achieve the same using\u0026nbsp;\u003Ca href=\"https:\/\/en.wikipedia.org\/wiki\/Cron\" target=\"_blank\"\u003Ecron\u003C\/a\u003E\u0026nbsp;if thats your preference. I will post some examples towards the bottom.\u0026nbsp;\u003C\/div\u003E\u003Ch2 style=\"text-align: left;\"\u003EBackups\u0026nbsp;\u003C\/h2\u003E\u003Cdiv\u003EMy data lives on a nvme ssd attached to one of my servers. The snapshots live in a NAS drive. This does give some redundancy. To prepare for hardware failures I have another layer of protections. I keep a rsync based backup of the snapshots on 2 external usb ssd drives. One of those USB drives are stored outside of my home. Syncs on the hard drive kept away from my residence are only done a few times per annum. It still provides a reasonable redundant solution. It's all about the compromises ...\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ELets get into some tech details now.\u003C\/div\u003E\u003Ch2 style=\"text-align: left;\"\u003ETechnical Details\u003C\/h2\u003E\u003Cdiv\u003EI'm not gonna talk about setting up\u0026nbsp;\u003Ca href=\"https:\/\/en.wikipedia.org\/wiki\/Rsync\" target=\"_blank\"\u003Ersync\u003C\/a\u003E,\u0026nbsp;\u003Ca href=\"https:\/\/en.wikipedia.org\/wiki\/Cron\" target=\"_blank\"\u003Ecron\u003C\/a\u003E. Nor wanna go into details of\u0026nbsp;\u003Ca href=\"https:\/\/systemd.io\/\" target=\"_blank\"\u003Esystemd\u003C\/a\u003E\u0026nbsp;services and \u003Ca href=\"https:\/\/man.archlinux.org\/man\/systemd.service.5.en\" target=\"_blank\"\u003Etimers\u003C\/a\u003E. That's a subject of its own. I will provide some sample scripts though.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EFor\u0026nbsp;\u003Ca href=\"https:\/\/rsnapshot.org\/\" style=\"text-align: justify;\" target=\"_blank\"\u003Ersnapshot\u003C\/a\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u0026nbsp;I needed a configuration. The installation generally comes with a default well document config just as any good linux package.\u0026nbsp;\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003EExample Section\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgsRn7bD68x_Cb_WHOAqR0S1kZu4FyYvP_Kr_riZEXaOGwyCy_07rxjWxMqhv65QTzddRe3dVXE_V-qCiMViLcHsinuePOYOkWniP4HOz2ews77e0z7R_6859GO1lcKUYBW80P-c9PIxYkRIm_R1JsjqXnQjvuAUeCaCznRlOh6Z02sAzNyChJatqCRxe8\/s1072\/Screenshot%202025-01-22%20at%207.11.20%E2%80%AFAM.png\" style=\"clear: left; float: left; margin-bottom: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"496\" data-original-width=\"1072\" height=\"296\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgsRn7bD68x_Cb_WHOAqR0S1kZu4FyYvP_Kr_riZEXaOGwyCy_07rxjWxMqhv65QTzddRe3dVXE_V-qCiMViLcHsinuePOYOkWniP4HOz2ews77e0z7R_6859GO1lcKUYBW80P-c9PIxYkRIm_R1JsjqXnQjvuAUeCaCznRlOh6Z02sAzNyChJatqCRxe8\/w640-h296\/Screenshot%202025-01-22%20at%207.11.20%E2%80%AFAM.png\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cbr \/\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003EThe settings are self explanatory. Some notable ones are explained hereafter.\u0026nbsp;\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"text-align: justify;\"\u003EBackup levels section was changed from the default \"alpha,beta....\" to read \"daily, weekly, monthly\" to increase readability. The number next to it denotes how many backups of each category are stored.\u0026nbsp;\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjDi5Sd3voMU-CYtK9IzPlch898JgeK2OZAWDd2i1tYiZu57kXtVnqf0kpVM0XlSF_pPYteESCavfNm-KWiMQ9U5mdMkpyz2hG3jJAPMncj4w8rztlrC8QaGJUeJRSsT8wKk8jk_as6-1pPVNkALV9uPS5Y2qjvoVy1ZvG7YpP1-s7ceZILoWOiTzH3DO0\/s678\/Screenshot%202025-01-22%20at%207.19.08%E2%80%AFAM.png\" style=\"clear: left; float: left; margin-bottom: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"402\" data-original-width=\"678\" height=\"238\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjDi5Sd3voMU-CYtK9IzPlch898JgeK2OZAWDd2i1tYiZu57kXtVnqf0kpVM0XlSF_pPYteESCavfNm-KWiMQ9U5mdMkpyz2hG3jJAPMncj4w8rztlrC8QaGJUeJRSsT8wKk8jk_as6-1pPVNkALV9uPS5Y2qjvoVy1ZvG7YpP1-s7ceZILoWOiTzH3DO0\/w400-h238\/Screenshot%202025-01-22%20at%207.19.08%E2%80%AFAM.png\" width=\"400\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003EThis means I have a in time snapshot of my data for the last 7 days. In time backup of my data for last 5 weeks etc.\u003Cbr \/\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EThe\u0026nbsp;\u003Ca href=\"https:\/\/systemd.io\/\" target=\"_blank\"\u003Esystemd\u003C\/a\u003E\u0026nbsp;service file thats used to run the snapshots are given below. The filename of the service is 'ranapshot@.service'. This becomes relevant when writing the systemd timers.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: left;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEisQP4jlInzYO8zTM7uhVKdJfzqDi1gnxaNiaRPWIkyw0hzEsDnKESWBGXIp2kUhdViz_CDb2oil-2hcQ0tvBZO6g4Y8POKZSzEiFK3AHQMMTnfQcpU7URVJ4PSOePqVICm4Pr8xvfVkMWiA7_IrxHX4LqflwFVERxX3sgNrCaOw_-8TFymlZibFe0K-9w\/s564\/Screenshot%202025-01-22%20at%207.28.55%E2%80%AFAM.png\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"342\" data-original-width=\"564\" height=\"194\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEisQP4jlInzYO8zTM7uhVKdJfzqDi1gnxaNiaRPWIkyw0hzEsDnKESWBGXIp2kUhdViz_CDb2oil-2hcQ0tvBZO6g4Y8POKZSzEiFK3AHQMMTnfQcpU7URVJ4PSOePqVICm4Pr8xvfVkMWiA7_IrxHX4LqflwFVERxX3sgNrCaOw_-8TFymlZibFe0K-9w\/s320\/Screenshot%202025-01-22%20at%207.28.55%E2%80%AFAM.png\" width=\"320\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003EThen each snapshot has it's own timer set to run at the correct cadence\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgdywQCWxdnPTm6wKiwtA_Yn3K656CMoMN6H0Yq4_0bupXboqm2InJpPFWWXclJXZG6IZQsHFWrNO63EIsHJlJrPYEkEAA5FM3cms8G5b5ZGj3nTEImb-W3YMFBkHvD_AFnwHl0CL60_23oVmEG70z70ULvLQud2UeRT_cFaq10rPrVxDxWzgHYKsaH_oc\/s764\/Screenshot%202025-01-28%20at%207.29.12%E2%80%AFAM.png\" imageanchor=\"1\" style=\"clear: left; float: left; margin-bottom: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"458\" data-original-width=\"764\" height=\"240\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgdywQCWxdnPTm6wKiwtA_Yn3K656CMoMN6H0Yq4_0bupXboqm2InJpPFWWXclJXZG6IZQsHFWrNO63EIsHJlJrPYEkEAA5FM3cms8G5b5ZGj3nTEImb-W3YMFBkHvD_AFnwHl0CL60_23oVmEG70z70ULvLQud2UeRT_cFaq10rPrVxDxWzgHYKsaH_oc\/w400-h240\/Screenshot%202025-01-28%20at%207.29.12%E2%80%AFAM.png\" width=\"400\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cbr \/\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cp\u003E\u003C\/p\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EThat's a wrap forks. Hope you enjoyed.\u0026nbsp;\u003C\/div\u003E\u003Cbr \/\u003E\u003Cbr \/\u003E\u003Cbr \/\u003E\u003Cbr \/\u003E\u003Cbr \/\u003E\u003C\/div\u003E"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/6635671021202968763\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2025\/01\/backups-snapshots.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/6635671021202968763"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/6635671021202968763"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2025\/01\/backups-snapshots.html","title":"Backups \u0026 Snapshots"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEiktqDG5QJ4kesGRFawvehjon0jGr6Gf3Gj4sQfsuXIV1JeaTMLyoRraY5UTSUV3xgHCxWonZYS9pLHWqoCVzd_7SDi2OjczHjsUuzuKlnzqEY4-ASPnqIIxzd3O6Jt97Vb5qqnIfzlMq225uksXNz4leN1B8S8EQDx4_-PojrRxwl9ZaaTW99Z7dzb6jA\/s72-w200-h200-c\/Gemini_Generated_Image_4rraly4rraly4rra%20(1).jpeg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-3182209324262181761"},"published":{"$t":"2024-10-18T10:17:00.000-07:00"},"updated":{"$t":"2024-10-18T10:19:45.536-07:00"},"title":{"type":"text","$t":"My Home Network Setup"},"content":{"type":"html","$t":"\u003Cdiv\u003EIt’s been 3 long years since I published anything to this blog …. 😳\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Ctable align=\"center\" cellpadding=\"0\" cellspacing=\"0\" class=\"tr-caption-container\" style=\"margin-left: auto; margin-right: auto;\"\u003E\u003Ctbody\u003E\u003Ctr\u003E\u003Ctd style=\"text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgghO7BY-wwYlL_DixM17Bj1hzWvXGszmbgI5yNQq2l0N99JTr65o1CpZ679rdni0g2LNkDB1Cbp5_cv71BLOrlq7ug3fg5ccmZlWavo7fzU3xJCs4Lrh3OTXestBFX5bXsrKKA_AOAvHUUrQPxN3Edjznyu8Zgy88eWzTRijI81ASyWz91Cm6xViO-imU\/s4032\/IMG_0537.jpeg\" style=\"margin-left: auto; margin-right: auto;\"\u003E\u003Cimg border=\"0\" data-original-height=\"3024\" data-original-width=\"4032\" height=\"240\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgghO7BY-wwYlL_DixM17Bj1hzWvXGszmbgI5yNQq2l0N99JTr65o1CpZ679rdni0g2LNkDB1Cbp5_cv71BLOrlq7ug3fg5ccmZlWavo7fzU3xJCs4Lrh3OTXestBFX5bXsrKKA_AOAvHUUrQPxN3Edjznyu8Zgy88eWzTRijI81ASyWz91Cm6xViO-imU\/s320\/IMG_0537.jpeg\" width=\"320\" \/\u003E\u003C\/a\u003E\u003C\/td\u003E\u003C\/tr\u003E\u003Ctr\u003E\u003Ctd class=\"tr-caption\" style=\"text-align: center;\"\u003E\u003Cspan style=\"font-size: x-small;\"\u003EThe not so pretty setup !\u003C\/span\u003E\u003C\/td\u003E\u003C\/tr\u003E\u003C\/tbody\u003E\u003C\/table\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EIn this article I’m gonna write about my home network setup. It’s important to note that the decisions and components used here were subjective to,\u003C\/div\u003E\u003Cdiv\u003E\u003Col\u003E\u003Cli\u003EReuse of components from the previous setup\u003C\/li\u003E\u003Cli\u003EWhat I could source conveniently without breaking the bank\u003C\/li\u003E\u003Cli\u003EMy technology preferences\u003C\/li\u003E\u003C\/ol\u003E\u003Cdiv\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EBefore I start rambling about the technicalities, let’s highlight what I needed the setup to do.\u003C\/div\u003E\u003Cdiv\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EThe basics like an internet connection, Wi-Fi for the mobile devices, Good connectivity throughout the house. Then the not so obvious need to run a lightweight web server, file server, different surveillance cameras, NVR system, IoT devices, home automation system, remote access for surveillance monitoring and lightweight administration. Let’s list them all.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Col style=\"text-align: left;\"\u003E\u003Cli\u003EReliable internet connections with enough juice\u003C\/li\u003E\u003Cli\u003EMesh capable WiFi solution\u0026nbsp;\u003C\/li\u003E\u003Cli\u003EAbility to have multiple isolated networks\u0026nbsp;\u003C\/li\u003E\u003Cli\u003EEasily extensible for future use cases\u003C\/li\u003E\u003Cli\u003EShareable wired and wireless connectivity\u0026nbsp;\u003C\/li\u003E\u003Cli\u003EPower over Ethernet\u0026nbsp;\u003C\/li\u003E\u003Cli\u003ERemote access\u0026nbsp;\u003C\/li\u003E\u003C\/ol\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EWhat about privacy and security. When trying to achieve all the above mentioned requirements I’m exposing myself to a whole lot of privacy and security vulnerabilities. There’s no way of fully eliminating the risk. Well you simply cannot have the cake and eat it too. I had to find a good middle ground. This middle ground however is gonna be an ever changing one. With time new risks will emerge and will need mitigation. Here’s me hoping I won’t be finding those new risks the hard way. Fingers crossed.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ELet’s jump right into the components and software solutions I ended up using.\u003C\/div\u003E\u003Cdiv\u003E\u003Col style=\"text-align: left;\"\u003E\u003Cli\u003EpfSense open source software based router\u003C\/li\u003E\u003Cli\u003Ei5-5200U based mini pc with 4x 1gig network ports as the router host\u003C\/li\u003E\u003Cli\u003EUnify layer 2 switch (VLAN capable)\u003C\/li\u003E\u003Cli\u003EUnify wireless access points\u0026nbsp;\u003C\/li\u003E\u003Cli\u003ECAT6a shielded cabling\u0026nbsp;\u003C\/li\u003E\u003Cli\u003EBlue Iris Software based NVR\u003C\/li\u003E\u003Cli\u003ELow power CPU and a low power GPU with CUDA support (for AI)\u003C\/li\u003E\u003Cli\u003EHome Assistant open source home automation software\u003C\/li\u003E\u003Cli\u003EZigbee2MQTT bridge\u003C\/li\u003E\u003Cli\u003ERTSP compatible POE cameras\u003C\/li\u003E\u003Cli\u003EWireGuard, Suricata, \u0026nbsp;pfSense firewall for VPN, IPS, IDS\u003C\/li\u003E\u003C\/ol\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ELooking into router solutions pfSense and OPNSense was the solutions that had the customizability I needed. It’s really hard to choose one over the other given they are both based on FreeBSD and had a shared codebase until OPNSense forked out on 2015. It came down to personal preference. Unify dream machine solutions was a close second but lacked the customizability and the flexibility of running on my own hardware.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EI chose a i55200U based mini pc to keep the power consumption low while having enough power with AES-NI. The 4 1gig Intel NICs gave me enough isolated throughput.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EUnify layer 2 switch gave me the ability to configure VLANs and port restrictions to keep different networks defined in the pfSense router secure and isolated. This also means I get a good ecosystem with my chosen unify access points. I couldn’t really find any alternative to this setup at a comparable price point.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EBlue Iris is a software based NVR. I’m not thrilled about having to run a windows machine for this purpose as Blue Iris only has a windows app. I was also looking into Synology NVRs. In the end the Blue Iris won due to the user base and price point.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EI choose Suricata over Snort for IPS, IDS. I already had it running on my web server, thus had a good base configuration that would keep working with minor tweaks. I used a WireGuard tunnel for remote access over OpenVPN. \u0026nbsp;WireGuard had everything I needed and comes bundled in the FreeBSD kernel used in pfSense.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EThat’s a wrap. In my next post I’m planning to cover how I’m using pfSense with the layer 2 switch and access points to achieve multiple isolated wireless and wired networks …\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003C\/div\u003E\u003C\/div\u003E\u003C\/div\u003E"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/3182209324262181761\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2024\/10\/my-home-network-setup.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/3182209324262181761"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/3182209324262181761"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2024\/10\/my-home-network-setup.html","title":"My Home Network Setup"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgghO7BY-wwYlL_DixM17Bj1hzWvXGszmbgI5yNQq2l0N99JTr65o1CpZ679rdni0g2LNkDB1Cbp5_cv71BLOrlq7ug3fg5ccmZlWavo7fzU3xJCs4Lrh3OTXestBFX5bXsrKKA_AOAvHUUrQPxN3Edjznyu8Zgy88eWzTRijI81ASyWz91Cm6xViO-imU\/s72-c\/IMG_0537.jpeg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-6454489257194893936"},"published":{"$t":"2021-03-27T17:01:00.060-07:00"},"updated":{"$t":"2021-03-27T17:24:04.888-07:00"},"title":{"type":"text","$t":"Nextcloud and PHP8"},"content":{"type":"html","$t":"\u003Cp\u003E\u0026nbsp;\u003C\/p\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEiqMC_146qJJKGHqkwbj5QIMxJ3TGeibIvV9g-PidxQN9KIOyJrPl4QqJnsdbefs3PeEqawCKRdlEAhgkFMPYVwhkZRskhPoY5fVELAotJ8EVetew6WYVM0ENAYT5AbrJXOFAdXjw0E2m4\/s700\/nextcld.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"278\" data-original-width=\"700\" height=\"254\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEiqMC_146qJJKGHqkwbj5QIMxJ3TGeibIvV9g-PidxQN9KIOyJrPl4QqJnsdbefs3PeEqawCKRdlEAhgkFMPYVwhkZRskhPoY5fVELAotJ8EVetew6WYVM0ENAYT5AbrJXOFAdXjw0E2m4\/w640-h254\/nextcld.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cbr \/\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Ch2 style=\"text-align: center;\"\u003EPHP8 broke my Nextcloud instance !!!\u003C\/h2\u003E\u003Cdiv style=\"text-align: justify;\"\u003EI run a \u003Ca href=\"https:\/\/nextcloud.com\/\" target=\"_blank\"\u003ENextcloud\u003C\/a\u003E instance on a self hosted \u003Ca href=\"https:\/\/archlinux.org\/\" target=\"_blank\"\u003EArch\u003C\/a\u003E\u0026nbsp;linux server. \u003Ca href=\"https:\/\/archlinux.org\/\" target=\"_blank\"\u003EArch\u003C\/a\u003E linux because I like the challenge 😁. \u003Ca href=\"https:\/\/nextcloud.com\/\" target=\"_blank\"\u003ENextcloud\u003C\/a\u003E due to privacy reasons. Recently Arch upgraded all php packages to \u003Ca href=\"https:\/\/www.php.net\/releases\/8.0\/en.php\" target=\"_blank\"\u003Ephp8\u003C\/a\u003E. Nextcloud 20.x, the version I'm using currently can't work on php8 thus my instance went offline. In this article I'm gonna describe how I managed to fix this issue.\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003EI run \u003Ca href=\"https:\/\/github.com\/teejee2008\/timeshift\" target=\"_blank\"\u003Etimeshift\u003C\/a\u003E in my server via the cli. So I could have downgraded the php version. That would mean I would have to refrain from updating php and risk zero day vulnerabilities. That would nullify the whole purpose of running a self hosted Nextcloud instance for my sensitive data. Instead I went with dual php versions. I run php-fpm with Apache in my server so now I have to figure out how to run Fast CGI process manager with 2 php versions in the same Apache server. If you are thinking \"well you are a SDE and you should know it\", let me start by saying I neither use PHP nor Apache in my day job. As a matter of fact none of the tech stacks mentioned here are used in my day job. The exception is linux of course.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003EArch \u003Ca href=\"https:\/\/archlinux.org\/news\/php-80-and-php-7-legacy-packages-are-available\/\" target=\"_blank\"\u003Eannounced\u003C\/a\u003E that PHP 7 legacy packages will be maintained with a limited support timeline. So I started by installing the required packages.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`sudo pacman -S php7 php7-fpm php7-gd php7-imagick php7-intl php7-apcu`\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003EConfirmed php7 is working by running \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`php7 -v`\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003ENow I need to enable the needed extensions. Edit the ini \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`sudo vim \/etc\/php7\/php.ini`\u003C\/i\u003E\u003C\/span\u003E and uncomment\/changed the following,\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eextension=gd\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eextension=iconv\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eextension=pdo_mysql\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eextension=bz2\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eextension=bcmath\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eextension=gmp\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eextension=intl\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eopcache.enable=1\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Ezend_extension=opcache\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Epocache.interned_strings_buffer=8\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eopcache.max_accelerated_files=10000\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eopcache.memory_consumpion=128\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eopcache.save_comments=1\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eopcache.revalidate_freq=1\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Ememory_limit = 2048\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eoutput _buffering = off\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003EEnable imagick in systemd service. \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E`sudo vim \/etc\/php7\/conf.d\/imagick.ini`\u003C\/span\u003E\u003C\/i\u003E and uncomment,\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eextension=imagick\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003EEdit the php-fpm systemd service \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`sudo vim \/etc\/php\/php-fpm.d\/www.conf`\u003C\/i\u003E\u003C\/span\u003E and uncomment\/change the following,\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eenv[HOSTNAME] = $HOSTNAME\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eenv[PATH] = \/usr\/local\/bin:\/usr\/bin:\/bin\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eenv[TMP] = \/tmp\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eenv[TMPDIR] = \/tmp\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eenv[TEMP] = \/tmp\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Erequest_terminate_timeout = 3600\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epm=dynamic\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epm.max_children=120\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epm.start_servers=12\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epm.min_spare-servers=6\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epm.max_spare_servers=18\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003EAdd the apcu settings to the php systemd service. \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E`sudo vim \/etc\/php7\/conf.d\/apcu.ini`\u003C\/span\u003E\u003C\/i\u003E and add\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eextension=apcu\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eapc.enable_cli=1\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eapc.tl=7200\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003ENow I need to tell my nextcloud virtual host to use the correct php version. Edit \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`\/etc\/httpd\/conf\/vhosts\/[my vhost].conf`\u003C\/i\u003E\u003C\/span\u003E and add the following,\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E\u0026lt;FilesMatch \\.php$\u0026gt;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E\u0026nbsp; \u0026nbsp; \u0026nbsp; SetHandler \"proxy:unix:\/run\/php-fpm7\/php-fpm.sock|fcgi:\/\/localhost\"\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E\u0026lt;\/FilesMatch\u0026gt;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003ERestart\/Start the systemd services\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`systemctl enable --now php-fpm7.service`\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`systemctl restart httpd`\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003EThat's it. My Nextcloud instance is up and running again. Most of the above settings\/configs are Nextcloud related optimizations I have done to my instance. I have a plan to write an article on setting up Nextcloud from scratch. Stay tuned...\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cp\u003E\u003C\/p\u003E"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/6454489257194893936\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2021\/03\/nextcloud-and-php8.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/6454489257194893936"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/6454489257194893936"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2021\/03\/nextcloud-and-php8.html","title":"Nextcloud and PHP8"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEiqMC_146qJJKGHqkwbj5QIMxJ3TGeibIvV9g-PidxQN9KIOyJrPl4QqJnsdbefs3PeEqawCKRdlEAhgkFMPYVwhkZRskhPoY5fVELAotJ8EVetew6WYVM0ENAYT5AbrJXOFAdXjw0E2m4\/s72-w640-h254-c\/nextcld.jpg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-5665316921391109233"},"published":{"$t":"2021-02-27T19:23:00.141-08:00"},"updated":{"$t":"2021-03-10T20:11:22.038-08:00"},"title":{"type":"text","$t":"Minimalist Static Website "},"content":{"type":"html","$t":"\u003Cp style=\"text-align: justify;\"\u003E\u0026nbsp;I wanted to build a minimalistic static website as the root of my domain. I have designed and built countless web applications using modern frameworks but nothing this simple. I wanted to use the least amount of tooling and keep things as simple as they can be. What I wanted to achieve was,\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003E\u003C\/p\u003E\u003Cul\u003E\u003Cli\u003EA simple website as the root of my domain.\u0026nbsp;\u003C\/li\u003E\u003Cli\u003ENeeds to be mobile first and fully responsive.\u003C\/li\u003E\u003Cli\u003EModern design.\u003C\/li\u003E\u003Cli\u003EShould work reasonably well with at least 80% of current web browser versions \u003Ca href=\"https:\/\/caniuse.com\/\" target=\"_blank\"\u003E(caniuse)\u003C\/a\u003E\u003C\/li\u003E\u003Cli\u003EThis needs to load as fast as it can without any bloat.\u003C\/li\u003E\u003Cli\u003EThe purpose of this root website is to direct the users into my various projects and articles.\u003C\/li\u003E\u003Cli\u003EThis will be hosted on my home server. So every byte, every style, every transformation counts.\u003C\/li\u003E\u003C\/ul\u003E\u003Cp\u003E\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003EMy first approach was to find a boilerplate. I was not able to find anything to my liking so I decided to start from scratch. What I used,\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003E\u003C\/p\u003E\u003Cul\u003E\u003Cli\u003EModule bundler loader: \u003Ca href=\"https:\/\/webpack.js.org\/\" target=\"_blank\"\u003Ewebpack\u003C\/a\u003E\u003C\/li\u003E\u003Cli\u003EJS compiler: \u003Ca href=\"https:\/\/babeljs.io\/\" target=\"_blank\"\u003EBabel\u003C\/a\u003E\u003C\/li\u003E\u003Cli\u003ECSS preprocessor: \u003Ca href=\"https:\/\/sass-lang.com\/dart-sass\" target=\"_blank\"\u003EDart Sass\u003C\/a\u003E\u003C\/li\u003E\u003Cli\u003EDesign \/ UX : \u003Ca href=\"https:\/\/material.io\/\" target=\"_blank\"\u003EMaterial Design\u003C\/a\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cp style=\"text-align: left;\"\u003EThe code I'm discussing is available in\u0026nbsp;\u003Ca href=\"https:\/\/github.com\/kumudug\/static_web\" target=\"_blank\"\u003Egithub.com\/kumudug\/static_web\u003C\/a\u003E. (non production quick and dirty poc)\u003C\/p\u003E\u003Cp style=\"text-align: left;\"\u003ELive demo at :\u0026nbsp;\u003Ca href=\"https:\/\/coding-mayhem.com\/projects\/staticweb\" target=\"_blank\"\u003Ecoding-mayhem.com\/projects\/staticweb\u003C\/a\u003E\u003C\/p\u003E\u003Cp style=\"text-align: left;\"\u003ESince this is mostly a static website I'm not going to use any JS frameworks for functionality. When needed latest ES will be used with Babel. Different webpack entry points are used for modularization. The modules are combined into required pages by using\u0026nbsp;\u003Ca href=\"https:\/\/webpack.js.org\/plugins\/html-webpack-plugin\/\" target=\"_blank\"\u003EHtmlWebpackPlugin\u003C\/a\u003E. Each module would then compile their own styles, images, scripts into a single html file. Let me make the complex simple.\u003C\/p\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: left;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEinypfQgg7iaZwbou9JigpsNlFYlzFQUDiZaeuCCEgrLCAw_L7QcWvLtx6ITJRzKuguDLX0Vks74ka5aWWhDokRdAqrZdJ5UOFK_QGnr1NmIVMjb-VCHdjgV6-w3i7EhOCwWo9eO9Jt7Uo\/s397\/file_structure.jpg\" style=\"clear: left; float: left; margin-bottom: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"397\" data-original-width=\"150\" height=\"482\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEinypfQgg7iaZwbou9JigpsNlFYlzFQUDiZaeuCCEgrLCAw_L7QcWvLtx6ITJRzKuguDLX0Vks74ka5aWWhDokRdAqrZdJ5UOFK_QGnr1NmIVMjb-VCHdjgV6-w3i7EhOCwWo9eO9Jt7Uo\/w217-h482\/file_structure.jpg\" width=\"217\" \/\u003E\u003C\/a\u003E\u003Cspan style=\"text-align: justify;\"\u003EThe common styles are contained in the sass files in the \"src\" root directory. These contain\u003C\/span\u003E\u003C\/div\u003E\u003Cp style=\"text-align: justify;\"\u003E\u003C\/p\u003E\u003Cp\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp; \u0026nbsp; 1. Common colors\u003C\/span\u003E\u003Cbr \/\u003E\u003C\/p\u003E\u003Cp\u003E\u003Cspan\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp; \u0026nbsp; 2. Material theme and styles with customizations\u003C\/span\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/p\u003E\u003Cp\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp; \u0026nbsp; 3. Material component style imports (buttons, cards etc.)\u003C\/span\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/p\u003E\u003Cp\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp; \u0026nbsp; 4. Common application styles\u003C\/span\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003EThese were all combined in \"app.scss\" and imported into a common module using the \"app.js\" file.\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003EEach page is organized into a folder inside the root \"src\" folder. These folders contains,\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp; \u0026nbsp; 1. Sass file containing the page styles\u003C\/span\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp; \u0026nbsp; 2. JavaScript\u0026nbsp;file containing the page code\u003C\/span\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp; \u0026nbsp; 3. Html file containing the page template\u003C\/span\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003EThese are then combined using the \u003Ca href=\"https:\/\/webpack.js.org\/plugins\/html-webpack-plugin\/\" target=\"_blank\"\u003EHtmlWebpackPlugin\u003C\/a\u003E.\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/p\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhGXbKsUSZYm1JViEf0BNfZ4K2viNfnL2T-xGVwFAinsDRzNXyulwQWyQkQiOVgFVgmuMMHFteS4Q2zWoQn0EuNdeonHPrz02hgxpdhJvSmUU3iCShV-4QFFrep3rHa0GNKyTEGhhHumjw\/s288\/home.jpg\" style=\"clear: right; float: right; margin-bottom: 1em; margin-left: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"134\" data-original-width=\"288\" height=\"186\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhGXbKsUSZYm1JViEf0BNfZ4K2viNfnL2T-xGVwFAinsDRzNXyulwQWyQkQiOVgFVgmuMMHFteS4Q2zWoQn0EuNdeonHPrz02hgxpdhJvSmUU3iCShV-4QFFrep3rHa0GNKyTEGhhHumjw\/w401-h186\/home.jpg\" width=\"401\" \/\u003E\u003C\/a\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003EApart from the above main concepts,\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Ca href=\"https:\/\/www.npmjs.com\/package\/html-webpack-inline-svg-plugin\" target=\"_blank\"\u003EHtmlWebpackInlineSvg\u003C\/a\u003E plugin was used to inline the SVG images. With SVGs inline it's possible to use sass to control svg properties.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Ca href=\"https:\/\/www.npmjs.com\/package\/eslint-webpack-plugin\" target=\"_blank\"\u003EEslintWebpackPlugin\u003C\/a\u003E is used for linting JavaScript.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Ca href=\"https:\/\/webpack.js.org\/plugins\/mini-css-extract-plugin\/\" target=\"_blank\"\u003EMiniCssExtractPlugin\u003C\/a\u003E is used to extract the styles out into separate files.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Ca href=\"https:\/\/www.npmjs.com\/package\/terser-webpack-plugin\" target=\"_blank\"\u003ETerserWebpackPlugin\u003C\/a\u003E to minify JavaScript.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003ESomething to note, The import and extension of Material is generally heavily dependent on the version used. Material tend to introduce breaking changes with every major release.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003EThe project contains several scripts\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E1. `\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Enpm start\u003C\/i\u003E\u003C\/span\u003E` - Would run the project inside a dev server\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E2. `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Enpm run build:dev\u003C\/span\u003E\u003C\/i\u003E` - Would build a development release package\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E3. `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Enpm run build:prod\u003C\/span\u003E\u003C\/i\u003E` - Would build a prod release package\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003EThat's a wrap.\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cbr \/\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003Cp\u003E\u003C\/p\u003E\u003Cp\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/p\u003E\u003Cp\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/p\u003E\u003Cp\u003E\u003C\/p\u003E\u003Cp style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/p\u003E\u003Cp\u003E\u003C\/p\u003E\u003Cp style=\"text-align: left;\"\u003E\u003Cbr \/\u003E\u003C\/p\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cp\u003E\u003C\/p\u003E\u003C\/div\u003E"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/5665316921391109233\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2021\/02\/minimalist-static-website.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/5665316921391109233"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/5665316921391109233"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2021\/02\/minimalist-static-website.html","title":"Minimalist Static Website "}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEinypfQgg7iaZwbou9JigpsNlFYlzFQUDiZaeuCCEgrLCAw_L7QcWvLtx6ITJRzKuguDLX0Vks74ka5aWWhDokRdAqrZdJ5UOFK_QGnr1NmIVMjb-VCHdjgV6-w3i7EhOCwWo9eO9Jt7Uo\/s72-w217-h482-c\/file_structure.jpg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-2238747154747798126"},"published":{"$t":"2020-12-29T17:17:00.000-08:00"},"updated":{"$t":"2020-12-29T17:17:27.406-08:00"},"title":{"type":"text","$t":"Arch Install Desktop vs Server Differences"},"content":{"type":"html","$t":"\u003Cp\u003E\u0026nbsp;\u003C\/p\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgOj0t3NkwYRU8Rwg_0LDp6CndOSLrlZqz-sB1XiOgL8XWY8cO7HUZ-MEJd3d6NnoFa4hH0wUGkJYu76dHbM5KnZkKglLHQEduW46I8KOQYx3rC9q1U3CrLn52Cb1zTH5f5Mb7Fcf4qUps\/s728\/archey.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"355\" data-original-width=\"728\" height=\"312\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgOj0t3NkwYRU8Rwg_0LDp6CndOSLrlZqz-sB1XiOgL8XWY8cO7HUZ-MEJd3d6NnoFa4hH0wUGkJYu76dHbM5KnZkKglLHQEduW46I8KOQYx3rC9q1U3CrLn52Cb1zTH5f5Mb7Fcf4qUps\/w640-h312\/archey.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cbr \/\u003E\u003Cp\u003E\u003C\/p\u003E\u003Cp\u003EIn my day to day life I use 4 devices running \u003Ca href=\"https:\/\/archlinux.org\/\" target=\"_blank\"\u003EArch Linux\u003C\/a\u003E.\u0026nbsp;\u003C\/p\u003E\u003Cp\u003E\u003C\/p\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003EXPS13 laptop\u003C\/li\u003E\u003Cli\u003ERyzen 7 3700X on a B550 itx system\u003C\/li\u003E\u003Cli\u003ERyzen 3 3100 on a A520 as my home server\u003C\/li\u003E\u003Cli\u003EXeon E3-1260L on a LGA 1155 running \u003Ca href=\"https:\/\/nextcloud.com\/\" target=\"_blank\"\u003ENext Cloud\u003C\/a\u003E and a web server\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv style=\"text-align: justify;\"\u003EIn this post I will outline the differences in my Arch Linux install among these devices. Before proceeding I should point out that using a rolling release on a server environment poses additional challenges that generally cannot be justified for production systems.\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003EThe first step is to boot using an Arch Linux USB. Then I generally start by creating a 260MB EFI partition using fdisk. The boot partition needs to be formatted as a Fat 32 partition.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emkfs.fat -F32 \/dev\/sda1\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003EThe rest of the partitions are going to be on \u003Ca href=\"https:\/\/en.wikipedia.org\/wiki\/Logical_Volume_Manager_(Linux)#:~:text=In%20Linux%2C%20Logical%20Volume%20Manager,systems%20on%20a%20logical%20volume.\" target=\"_blank\"\u003ELVM\u003C\/a\u003E. First step is to create a partition in the remainder of the disk and choose LVM as the partition type. Once done I would create the physical volume and a volume group.\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epvcreate \/dev\/sda2\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Evgcreate vgmain \/dev\/sda2\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003ENow I would create the root partition in the volume group. For desktop installations the root partition is going to be LUKS encrypted. This ensures the os is encrypted at rest and tamper proof if physical access is compromised.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cul\u003E\u003Cli\u003EDesktop\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Elvcreate -L 30G vgmain -n cryptlvroot\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ecryptsetup -u -v luksFormat \/dev\/vgmain\/cryptlvroot\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003EServer\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Elvcreate -L 30G vgmain -n lvroot\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003ENow I would proceed to format the root partition. For the desktop, the LUKS partition needs to be opened before it can be formatted\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cul\u003E\u003Cli\u003EDesktop\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Ecryptsetup open \/dev\/vgmain\/cryptlvroot root\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emkfs.ext4 \/dev\/mapper\/root\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003EServer\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkfs.ext4 \/dev\/vgmain\/lvroot\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003Cdiv\u003ENow I'm at the point of creating the data partition. For the desktop the data partition will be mounted as the home volume. For servers I generally create a different mount point for this. I always encrypt the data partition using LUKS.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Elvcreate -l +100%FREE vgmain -n lvdata\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ecryptsetup -y -v luksFormat \/dev\/vgmain\/lvdata\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ecryptsetup open \/dev\/vgmain\/lvdata encdata\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkfs.ext4 \/dev\/mapper\/encdata\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EIt's time to mount all the file systems. Before that lets talk about the way the LUKS volumes are to be unlocked during boot.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EFor the desktop installations I would configure a password prompt during initial ramdisk creation (explained later in this post). The password prompt will unlock the root encrypted volume. The data volume will them be unlocked using a key file stored within the root volume.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EFor server installations it doesn't make sense to keep the data partition key in the root volume as the root volume is not encrypted. I choose to store the key in a usb flash drive. This approach gives me a better chance of keeping the data encrypted at rest by removing the usb drive from the servers in case of a physical security compromise. I do take additional steps to protect the keys stored in the USB drive but these steps would fall under security by obscurity at best and is beyond the scope of this post.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003EDesktop\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emount \/dev\/mapper\/root \/mnt\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emkdir \/mnt\/boot\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emount \/dev\/sda1 \/mnt\/boot\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emkdir \/mnt\/home\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emount \/dev\/mapper\/encdata \/mnt\/home\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003EServer\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emount \/dev\/vgmain\/lvroot \/mnt\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkdir \/mnt\/boot\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emount \/dev\/sda1 \/mnt\/boot\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkdir \/mnt\/data\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emount \/dev\/mapper\/encdata \/mnt\/data\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkdir \/mnt\/keys\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emount \/dev\/sdc1 \/mnt\/keys\u003C\/span\u003E\u003C\/i\u003E (sdc1 is a usb flash drive)\u003C\/li\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003Cdiv\u003ENow that the partitions are mounted I'm ready to create the file system table.\u0026nbsp;\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkdir \/mnt\/etc\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Egenfstab -U \/mnt \u0026gt;\u0026gt; \/mnt\/etc\/fstab\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ELets continue the installation. I'm gonna verify the UEFI boot was used by looking at efi vars.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003Els \/sys\/firmware\/efi\/efivars\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EIn a desktop installation with wifi capabilities now would be the time to connect.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eiwctl\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Estation wlan0 connect [ssid]\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Estation wlan0 show\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Equit\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EVerify a live internet connection by pinging archlinux.org\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E\u0026nbsp;ping archlinux.org\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ESet and check network time protocol\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Etimedatectl set-ntp true\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Etimedatectl status\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EInstall the essentials. For server environments I use the lts kernel. For desktop environments I use the latest kernel but still install the lts kernel which can be used to recover the system without using chroot.\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003EServer\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epacstrap \/mnt base linux-lts linux-firmware linux-lts-headers\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003EDesktop\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epacstrap \/mnt base linux linux-firmware linux-headers linux-lts linux-lts-headers\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003Cdiv\u003ESwitch to the newly installed root and setup swap\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Earch-chroot \/mnt\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Efree (check memory and decide how much swap you need)\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Edd if=\/dev\/zero of=\/swapfile bs=1M count=8192 status=progress\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Echmod 600 \/swapfile\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emkswap \/swapfile\u0026nbsp;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eecho '\/swapfile none swap 0 0' | tee -a \/etc\/fstab\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ESetup auto mounting of the encrypted data volume by creating a key and adding it to crypttab. Install editors and lvm packages as well\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Epacman -Sy vim nano lvm2\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Edd if=\/dev\/urandom of=\/keys\/data-key bs=512 count=8\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Ecryptsetup luksAddKey \/dev\/vgmain\/lvdata \/keys\/data-key\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EUse \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`lsblk -a`\u003C\/i\u003E\u003C\/span\u003E to find the UUID fo the encrypted data partition and add it to the \/etc\/crypttab file\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eencdata UUID=[xxxxx-xxxxx-xxxx...] \/keys\/data-key\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ESet timezone and locale\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eln -sf \/usr\/share\/zoneinfo\/America\/Los_Angeles \/etc\/localtime\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ehwclock --systohc\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Evim \/etc\/locale.gen\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003EUncomment your locale. Ex: \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Een_US.UTF-8 UTF-8\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Elocale-gen\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Evim \/etc\/locale.conf\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003EEnter your locale. Ex: \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003ELANG=en_US.UTF-8\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ENetwork config\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Evim \/etc\/hostname\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003EEnter \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E[name]\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Evim \/etc\/hosts\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003EEnter\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E127.0.0.1\u003Cspan\u003E\u0026nbsp; \u0026nbsp; localhost\u003C\/span\u003E\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E::1\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/span\u003E\u003Cspan\u003E\u0026nbsp; \u0026nbsp;localhost\u003C\/span\u003E\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E127.0.1.1\u003Cspan\u003E\u0026nbsp; \u0026nbsp; [name].localdomain [name]\u003C\/span\u003E\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003ESetting root password and creating additional\u0026nbsp;users\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epasswd - enter root password\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Euseradd -g users -G power,storage,wheel -m [youradminaccount]\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epasswd [youradminaccount]\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003EInstall and configure sudo so that everyone in wheel user group can sudo after confirming password.\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Epacman -S sudo\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Evisudo\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003EUncomment \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E%wheel ALL=(ALL) ALL\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003EInstall network tools and base dev tools. Enable systemd services for networking\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epacman -S base-devel networkmanager dialog\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Esystemctl enable NetworkManager\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Esystemctl enable systemd-networkd.service\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Esystemctl enable systemd-resolved.service\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003EConfigure initial ram disk creation. For desktop environments we need to configure luks and lvm as we need to unlock the LUKS encrypted root volume in the lvm partition. For server environments we only need to configure lvm.\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003EServer\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Evim \/etc\/mkinitcpio.conf\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003EInsert lvm2 bwetween block and filesystem\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003EHOOKS=(base udev autodetect modconf block __lvm2__ filesystems keyboard fsck)\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003ERecreate initramfs image\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkinitcpio -P linux-lts\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003EDesktiop\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Evim \/etc\/mkinitcpio.conf\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003EInsert lvm2 and encrypt hooks like so\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003EHOOKS=(base udev autodetect __keyboard__ consolefont modconf block __lvm2__ __encrypt__ filesystems fsck)\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003ERecreate initramfs images for lts and general kernel\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkinitcpio -P linux-lts\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkinitcpio -P linux\u003C\/span\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003Cdiv\u003EInstall the processor microcode\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Epacman -S intel-ucode\u003C\/i\u003E\u003C\/span\u003E OR \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epacman -S amd-ucode\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EInstall bootloader\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epacman -S grub efibootmgr mtools\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ESet the kernel parameters for grub\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003EServer\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Evim \/etc\/default\/grub\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eroot=dev\/vgmain\/lvroot\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003EThis needs to be added to\u0026nbsp;\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003EGRUB_CMDLINE_LINUX\u003C\/span\u003E\u003C\/i\u003E\u0026nbsp;section\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003EDesktop\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Evim \/etc\/default\/grub\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ecryptdevice=UUID=xxxx-xxxx-xxxx-xxxxxx:root root=\/dev\/mapper\/root\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003EThe UUID is of the cryptlvroot logical volume. This needs to be added to\u0026nbsp;\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003EGRUB_CMDLINE_LINUX\u003C\/span\u003E\u003C\/i\u003E section\u003C\/li\u003E\u003Cli\u003EYou can also add \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Elvm\u003C\/span\u003E\u003C\/i\u003E to\u0026nbsp;\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003EGRUB_PRELOAD_MODULES\u003C\/i\u003E\u003C\/span\u003E section, but this is optional as we will be using \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Egrub-mkconfig\u003C\/i\u003E\u003C\/span\u003E later\u003C\/li\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003Cdiv\u003ESetup GRUB\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Egrub-install --target=x86_64-efi --efi-directory=\/boot --bootloader-id=GRUB\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Egrub-mkconfig -o \/boot\/grub\/grub.cfg\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv\u003ETime to reboot into the new system\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eexit\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eumount -a\u003C\/span\u003E\u003C\/i\u003E [Safe to ignore any errors saying the system is busy]\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ereboot\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv\u003EThat's a wrap. :)\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cspan\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cp\u003E\u003C\/p\u003E"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/2238747154747798126\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2020\/12\/arch-install-desktop-vs-server.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/2238747154747798126"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/2238747154747798126"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2020\/12\/arch-install-desktop-vs-server.html","title":"Arch Install Desktop vs Server Differences"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgOj0t3NkwYRU8Rwg_0LDp6CndOSLrlZqz-sB1XiOgL8XWY8cO7HUZ-MEJd3d6NnoFa4hH0wUGkJYu76dHbM5KnZkKglLHQEduW46I8KOQYx3rC9q1U3CrLn52Cb1zTH5f5Mb7Fcf4qUps\/s72-w640-h312-c\/archey.jpg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-4828370950034223991"},"published":{"$t":"2020-11-26T12:36:00.001-08:00"},"updated":{"$t":"2020-11-26T12:36:12.828-08:00"},"title":{"type":"text","$t":"How I used Udev with systemd to configure an AIO water cooler in Linux"},"content":{"type":"html","$t":"\u003Cp\u003E\u0026nbsp;Let me start by telling a little bit of the back story. Recently I build a mini ITX desktop PC to help me with my more resource intensive programming work. I used a all in one liquid cooler in this PC. (\u003Ca href=\"https:\/\/www.nzxt.com\/products\/kraken-x63\" target=\"_blank\"\u003EKraken X63\u003C\/a\u003E). Setting something like this in Windows is quite straight forward, not so much in Linux. In this article I will detail what I did to get it working the way I wanted.\u0026nbsp;\u003C\/p\u003E\u003Cp\u003EFirst problem to solve was to find a driver\/controller software for the cooler, that works in Linux. These coolers come with only windows driver\/controller software with no official Linux support. Thanks to the awesome open source community there is a cross platform tool that I can use to talk to the API of the liquid cooler which is called \u003Ca href=\"https:\/\/github.com\/jonasmalacofilho\/liquidctl\" target=\"_blank\"\u003Eliquidctl\u003C\/a\u003E. To be more specific I had to use an experimental \u003Ca href=\"https:\/\/github.com\/jonasmalacofilho\/liquidctl\/tree\/1.4.x-branch\" target=\"_blank\"\u003Ebranch\u003C\/a\u003E of this tool that supported the API version of my hardware.\u0026nbsp;\u003C\/p\u003E\u003Cp\u003EInstallation is quite straight forward. My preferred Linux distro is \u003Ca href=\"https:\/\/www.archlinux.org\/\" target=\"_blank\"\u003EArch\u003C\/a\u003E. I used an already ported AUR repository of the mainline and switched to the branch I needed before building the source.\u0026nbsp;\u003C\/p\u003E\u003Cp\u003E\u003C\/p\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`git clone https:\/\/aur.archlinux.org\/liquidctl.git `\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`git checkout -b 1.4.x`\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`makepkg –sic`\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv\u003EThat's it for installation. Now to test it out\u003C\/div\u003E\u003Cdiv\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E`sudo liquidctl status`\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv\u003E[image]\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EThis is the default settings. Now I need to configure the cooler with values I need. I wanted to change 2 configuration options\u003C\/div\u003E\u003Cdiv\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003EThe cooler pump speed relative to the coolant temperature\u003C\/li\u003E\u003Cli\u003ESwitch off the decorative RGB lighting of the cooler\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv\u003EAfter referencing the API details given in the \u003Ca href=\"https:\/\/github.com\/jonasmalacofilho\/liquidctl\" target=\"_blank\"\u003Eliquidctl\u003C\/a\u003E package. I narrowed down the API calls I needed to do.\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`liquidctl set pump speed 25 25 30 30 34 80 40 90 50 100 `\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`liquidctl set ring color off`\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`liquidctl set logo color off`\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv\u003ENow that I have a way to control the cooler the next step is automating it. The cooler is not able to persist it's settings, so I need set these settings after every system boot. The way to do that is using \u003Ca href=\"https:\/\/en.wikipedia.org\/wiki\/Systemd\" target=\"_blank\"\u003Esystemd\u003C\/a\u003E. The steps involved are,\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003ECreate a systemd unit\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E`sudo vim \/etc\/systemd\/system\/liquidcfg.service`\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003EContents of the file\u003C\/li\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003C\/div\u003E\u003Cp\u003E\u003C\/p\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E[Unit]\u0026nbsp;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cdiv\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003EDescription=AIO startup service\u0026nbsp;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E\u003Cbr \/\u003E\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E[Service]\u0026nbsp;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003EType=oneshot\u0026nbsp;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003EExecStart=liquidctl set pump speed 25 25 30 30 34 80 40 90 50 100\u0026nbsp;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003EExecStart=liquidctl set ring color off\u0026nbsp;\u0026nbsp;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003EExecStart=liquidctl set logo color off\u0026nbsp;\u0026nbsp;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003ERemainAfterExit=yes\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E\u0026nbsp;\u0026nbsp;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E[Install]\u0026nbsp;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003EWantedBy=default.target\u0026nbsp;\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003ESetup the newly created unit to run on every boot\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`systemctl daemon-reload`\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`systemctl start liquidcfg`\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`systemctl enable liquidcfg`\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003Cdiv\u003EI thought that was all I needed to do. After a few restarts I started noticing the configurations failed to reflect intermittently. The root cause was a race condition. The cooler exposes it's API via an USB interface. If the interface drivers was loaded when the new systemd service executes everything goes smoothly not so otherwise. Thus simply running the systemd unit during startup is not an option. I used Udev to solve this race condition.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ca href=\"https:\/\/en.wikipedia.org\/wiki\/Udev\" target=\"_blank\"\u003EUdev\u003C\/a\u003E is the device manager for the Linux kernal. As mentioned the cooler has a internal usb connection. If I try to set the configuration before this connection is established the race condition occurs. Using Udev I can identify and trigger the settings change after the USB connection is established.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EFirst of all I needed to find the USB connection details. I used \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`lsusb`\u003C\/i\u003E\u003C\/span\u003E command. I had to install \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`usbutils`\u003C\/i\u003E\u003C\/span\u003E package to get this. If you are in a \u003Ca href=\"https:\/\/www.debian.org\/\" target=\"_blank\"\u003Edebian\u003C\/a\u003E based distro you probably will have this package already installed. \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`lsusb`\u003C\/i\u003E\u003C\/span\u003E gave me the following info about the device,\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003EBus 001 Device 004: ID 1e71:2007 NZXT NZXT USB Device\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EUsing the above information I created a new Udev rule file \u003Cspan style=\"color: #666666;\"\u003E`\/lib\/udev\/rules.d\/99-z-kraken.rules`\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003ESUBSYSTEM==\"usb\", ATTRS{idVendor}==\"1e71\", ATTRS{idProduct}==\"2007\", MODE=\"0666\", TAG+=\"systemd\", ENV{SYSTEMD_WANTS}+=\"liquidcfg.service\"\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003ESUBSYSTEM==\"hidraw\", SUBSYSTEMS==\"usb\", ATTRS{idVendor}==\"1e71\", ATTRS{idProduct}==\"2007\", MODE=\"0666\", TAG+=\"systemd\", ENV{SYSTEMD_WANTS}+=\"liquidcfg.service\"\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ENow I disabled the previous systemd service from automatically starting at startup\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`systemctl disable liquidcfg`\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EAfterwords reload the udev rules so that my new rule will take effect\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E`udevadm control --reload`\u003C\/i\u003E\u003C\/span\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EThat solved the race condition. Now I can view the status of the cooling system using `liquidctl status`\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEizcxssGU_KfwxriTM1CKbDaje3JObxYCWmN0TcAekP_kD7r4sXSREVMSGXH5YGtojCRmc-b1fE33WkVnkndZRhdWzPNQp-MFHoX7NWgH4SfD0mfzNj22MF2vjW2KehkZemVGT9kg5co3A\/s380\/status.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"102\" data-original-width=\"380\" height=\"172\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEizcxssGU_KfwxriTM1CKbDaje3JObxYCWmN0TcAekP_kD7r4sXSREVMSGXH5YGtojCRmc-b1fE33WkVnkndZRhdWzPNQp-MFHoX7NWgH4SfD0mfzNj22MF2vjW2KehkZemVGT9kg5co3A\/w640-h172\/status.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EThat's a wrap.\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003C\/div\u003E\u003Cp\u003E\u003Cbr \/\u003E\u003C\/p\u003E"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/4828370950034223991\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2020\/11\/how-i-used-udev-with-systemd-to.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/4828370950034223991"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/4828370950034223991"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2020\/11\/how-i-used-udev-with-systemd-to.html","title":"How I used Udev with systemd to configure an AIO water cooler in Linux"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEizcxssGU_KfwxriTM1CKbDaje3JObxYCWmN0TcAekP_kD7r4sXSREVMSGXH5YGtojCRmc-b1fE33WkVnkndZRhdWzPNQp-MFHoX7NWgH4SfD0mfzNj22MF2vjW2KehkZemVGT9kg5co3A\/s72-w640-h172-c\/status.jpg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-8049446503363794677"},"published":{"$t":"2020-09-27T10:36:00.010-07:00"},"updated":{"$t":"2020-10-04T16:51:11.893-07:00"},"title":{"type":"text","$t":"Nextcloud on Arch Linux (Encrypted System) [Part 01 - Preparation]"},"content":{"type":"html","$t":"\u003Cp style=\"text-align: justify;\"\u003E\u0026nbsp;In this series of posts I'm going to outline the process I used to create my own \u003Ca href=\"https:\/\/nextcloud.com\/\" target=\"_blank\"\u003ENextcloud\u003C\/a\u003E server.\u0026nbsp;\u003C\/p\u003E\u003Ch2 style=\"text-align: left;\"\u003ENetwork Setup\u003C\/h2\u003E\u003Cp style=\"text-align: justify;\"\u003EFirst step is to prepare my network. I'm planning to host this at home. Due to obvious security concerns I don't want to host this on my home network. I used \u003Ca href=\"https:\/\/www.pfsense.org\/\" target=\"_blank\"\u003EpfSense\u003C\/a\u003E\u0026nbsp;to achieve what I want. The pfSense setup is outside the scope of this post. If you do need help or is curious about setting up pfSense let me know. Given below is an overview diagram of my network,\u0026nbsp;\u003C\/p\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEg71wE2wfWQQ9cNUPp9YUGvvJcpnSyElF3wIbOAg5jt8pBElZQ2JgOWrzdldPdbK1BcqBNLrn0PxxrrOFFRQlm1ffiECb1MJhiKeu862HrPjZYgUzS1WMqHPnTBxpybOK9O-ARZy7YWV-Q\/s752\/network.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"752\" data-original-width=\"718\" height=\"640\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEg71wE2wfWQQ9cNUPp9YUGvvJcpnSyElF3wIbOAg5jt8pBElZQ2JgOWrzdldPdbK1BcqBNLrn0PxxrrOFFRQlm1ffiECb1MJhiKeu862HrPjZYgUzS1WMqHPnTBxpybOK9O-ARZy7YWV-Q\/w612-h640\/network.jpg\" width=\"612\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cbr \/\u003E\u003Cp style=\"text-align: justify;\"\u003EThe basic firewall rules in this network are\u003C\/p\u003E\u003Cp style=\"text-align: justify;\"\u003E\u003C\/p\u003E\u003Cul\u003E\u003Cli\u003EServer can't talk to the switch.\u0026nbsp;\u003C\/li\u003E\u003Cli\u003EServer can't talk to the pfSense router interface\u003C\/li\u003E\u003Cli\u003EServer interface has \u003Ca href=\"https:\/\/suricata-ids.org\/\" target=\"_blank\"\u003ESuricata\u003C\/a\u003E IDS\/IPS running.\u0026nbsp;\u003C\/li\u003E\u003C\/ul\u003E\u003Cp\u003E\u003C\/p\u003E\u003Ch2 style=\"text-align: left;\"\u003EDrive Preparation and Encryption\u003C\/h2\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ciframe allowfullscreen=\"\" class=\"BLOG_video_class\" height=\"266\" src=\"https:\/\/www.youtube.com\/embed\/cxMYR617a5E\" width=\"320\" youtube-src-id=\"cxMYR617a5E\"\u003E\u003C\/iframe\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Ch3 style=\"text-align: left;\"\u003ESecure erase the drive.\u0026nbsp;\u003C\/h3\u003E\u003Cdiv\u003E\u003Cb\u003ENote: The drive I used was an old HDD. What I didn't notice was that the partition table was of type DOS. Had to change that to GPT and repeat this process in order for it to work.\u003C\/b\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EI'm going to use a 250GB HDD for my instalation (not a SSD). First step is to secure erase the drive. \u003Ca href=\"https:\/\/wiki.archlinux.org\/index.php\/Dm-crypt\/Drive_preparation\" target=\"_blank\"\u003E(ref)\u003C\/a\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003EUse `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Elsblk\u003C\/span\u003E\u003C\/i\u003E` to identify the drive\u0026nbsp;\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjv_ud8-Nsc5bq6Vr5_bBR8xFY2CeJkwOlSIJ50MarcJOr-rBukSRiUZ2RfQ6b8MEqwUe6NO5CM0eyt8hJImnEyIqDkFj82qMyLOM2EkeuUaPTbJsrNxtJPbcfYuyCftGn78AIBRiPy8vg\/s591\/blog_01.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"402\" data-original-width=\"591\" height=\"435\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjv_ud8-Nsc5bq6Vr5_bBR8xFY2CeJkwOlSIJ50MarcJOr-rBukSRiUZ2RfQ6b8MEqwUe6NO5CM0eyt8hJImnEyIqDkFj82qMyLOM2EkeuUaPTbJsrNxtJPbcfYuyCftGn78AIBRiPy8vg\/w640-h435\/blog_01.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003ECreate a temporary container across the whole drive using `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ecryptsetup open --type plain -d \/dev\/random \/dev\/sda wipe\u003C\/span\u003E\u003C\/i\u003E`\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEj3CHA6hbiSva5g6gilPkRJOitG9vtNqtppXdjX-Kv11CjXne_SYpq8AyGHJZ0-aI6J2T-mPFHA1SdhLm7GtzdMmEUO23xZV28AdhWpLDHlGyta-Pzgr-DY81iWqW9avvrNeQ11ypWDgn8\/s702\/blog_02.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"345\" data-original-width=\"702\" height=\"314\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEj3CHA6hbiSva5g6gilPkRJOitG9vtNqtppXdjX-Kv11CjXne_SYpq8AyGHJZ0-aI6J2T-mPFHA1SdhLm7GtzdMmEUO23xZV28AdhWpLDHlGyta-Pzgr-DY81iWqW9avvrNeQ11ypWDgn8\/w640-h314\/blog_02.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003EWipe the container with zeroes using `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Edd if=\/dev\/zero of=\/dev\/mapper\/wipe status=progress bs=1M\u003C\/span\u003E\u003C\/i\u003E`. According to the \u003Ca href=\"https:\/\/wiki.archlinux.org\/index.php\/Dm-crypt\/Drive_preparation\" target=\"_blank\"\u003Edm-crypt documentation\u003C\/a\u003E\u0026nbsp;use of \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eif=\/dev\/urandom\u003C\/span\u003E\u003C\/i\u003E is not required as the encryption cipher is used for randomness.\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjGxBArnm3pwX_hTTwYXl5_iaxpQwLgnrZlwFR_YoOm7BjCe3V8TxWC0jn8eAF4dyjTxEjtygJqiT3gD6zepcBoTI-KekHTfyHENeAeLWJWzjDWH4wVBDM7-FW7YGxzjlrmEeRn3oLOg2Y\/s619\/blog_03.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"242\" data-original-width=\"619\" height=\"250\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjGxBArnm3pwX_hTTwYXl5_iaxpQwLgnrZlwFR_YoOm7BjCe3V8TxWC0jn8eAF4dyjTxEjtygJqiT3gD6zepcBoTI-KekHTfyHENeAeLWJWzjDWH4wVBDM7-FW7YGxzjlrmEeRn3oLOg2Y\/w640-h250\/blog_03.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003EClose the temporary container using `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ecryptsetup close wipe\u003C\/span\u003E\u003C\/i\u003E`\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhOaidkqmjzCzxEWnIaIuIkC1Ibe2elkIXq9YjC8grQWk98cqqstPQhAK-zl4oscC_uNSsm1sQH0NcJZWVhO8Iy5eTPjdL9f-s9ATtia1rALnjUAEefpFOji6oMXYl2gie1hjf_MepcIHg\/s865\/blog_04.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"519\" data-original-width=\"865\" height=\"384\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhOaidkqmjzCzxEWnIaIuIkC1Ibe2elkIXq9YjC8grQWk98cqqstPQhAK-zl4oscC_uNSsm1sQH0NcJZWVhO8Iy5eTPjdL9f-s9ATtia1rALnjUAEefpFOji6oMXYl2gie1hjf_MepcIHg\/w640-h384\/blog_04.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003EReboot and confirm that the drive is now empty and all the previous partitions are gone.\u003C\/li\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjD10V98TzvDu3F69wYjNHqAt896IbovfJifnX1whUAuQKylQivRnwWkTXB2oi_yWu6gTC7dNI7cFSgz_QBFMe5bHTxbjB7ZS8aLX9XVrLuizmCTUQwuNxlT9794QRjCMpm1aX5OWhjrJQ\/s600\/blog_05.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"363\" data-original-width=\"600\" height=\"388\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjD10V98TzvDu3F69wYjNHqAt896IbovfJifnX1whUAuQKylQivRnwWkTXB2oi_yWu6gTC7dNI7cFSgz_QBFMe5bHTxbjB7ZS8aLX9XVrLuizmCTUQwuNxlT9794QRjCMpm1aX5OWhjrJQ\/w640-h388\/blog_05.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Ch3 style=\"text-align: left;\"\u003ECreate efi boot and LUKS encrypted root partition\u003C\/h3\u003E\u003Cp\u003ENow that the partition table of the drive is empty, I first created 2 partitions for the efi boot (512MB) and root(50GB). The efi boot partitions type was set to \"ef\". (step by step shown in the video). The boot partition is the only partition that won't be encrypted in this system. USB and other boot options will be disabled in bios, and password protected. This will prevent bootable media attacks.\u0026nbsp;\u003C\/p\u003E\u003Cp\u003E\u003C\/p\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003ESince the efi boot partition won't be encrypted it can be directly formatted by using `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkfs.fat -F32 \/dev\/sda1\u003C\/span\u003E\u003C\/i\u003E`\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEg5otiK83kVI_8sXBCdlg_jgG4THBSwDNQhGjFCuuIx_nBjfHoic5sRJwmxMJSccjojDCG4p1gVMp0rKPYqYAYczD6dPv7AyQ2Lf7IFvFeKFEwuK_O5sOgsBm-Th_wirAAlcd1QBZZ7tGA\/s526\/blog_06.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"203\" data-original-width=\"526\" height=\"246\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEg5otiK83kVI_8sXBCdlg_jgG4THBSwDNQhGjFCuuIx_nBjfHoic5sRJwmxMJSccjojDCG4p1gVMp0rKPYqYAYczD6dPv7AyQ2Lf7IFvFeKFEwuK_O5sOgsBm-Th_wirAAlcd1QBZZ7tGA\/w640-h246\/blog_06.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003EThen I created the LUKS encrypted partition inside the previously created root partition using `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ecryptsetup -y -v luksFormat \/dev\/sda2\u003C\/span\u003E\u003C\/i\u003E`.\u0026nbsp;\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhnQ7dMZHljlq0Lo2pS2JTWzNGLTys52rCQwo-so7HSx1Cy3DuZwSxHbS1qCV-AwgYIVGK67_a0zvfR-biGddb6trd3WuX1sWGTKQLosFnCnANl3bE0al7I4ZUiVJ6r_Spk5_Ha-_WtaM8\/s700\/blog_07.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"410\" data-original-width=\"700\" height=\"374\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhnQ7dMZHljlq0Lo2pS2JTWzNGLTys52rCQwo-so7HSx1Cy3DuZwSxHbS1qCV-AwgYIVGK67_a0zvfR-biGddb6trd3WuX1sWGTKQLosFnCnANl3bE0al7I4ZUiVJ6r_Spk5_Ha-_WtaM8\/w640-h374\/blog_07.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003EThe newly created encrypted root needs to be opened and formatted. The paraphrase is needed for this operation. Afterwords it can be mounted.\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003EOpen: `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ecryptsetup open \/dev\/sda2 cryptroot\u003C\/span\u003E\u003C\/i\u003E`\u003C\/li\u003E\u003Cli\u003EFormat: `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkfs.ext4 \/dev\/mapper\/cryptroot\u003C\/span\u003E\u003C\/i\u003E`\u003C\/li\u003E\u003Cli\u003EMount: `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emount \/dev\/mapper\/cryptroot \/mnt\u003C\/span\u003E\u003C\/i\u003E`\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003ENow I\"m going to unmount and close the encrypted root and use it during the OS install.\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003EUnmount: `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eumount \/mnt\u003C\/span\u003E\u003C\/i\u003E`\u003C\/li\u003E\u003Cli\u003EClose: `\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ecryptsetup close cryptroot\u003C\/span\u003E\u003C\/i\u003E`\u003C\/li\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgUBLBjmRBzPmfQz-xOCXnbr9tbWJytYUqZN2ATtkBhFM-punFizi6zPBLsnc2aTcOXaiy_Dds7IBc5_UdMOhe8CtteEKU6QWRLX5ikigkLg5WPQfzUXQYOUILdSror-zmrhju6z6gUSc0\/s906\/blog.08.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"906\" data-original-width=\"719\" height=\"640\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgUBLBjmRBzPmfQz-xOCXnbr9tbWJytYUqZN2ATtkBhFM-punFizi6zPBLsnc2aTcOXaiy_Dds7IBc5_UdMOhe8CtteEKU6QWRLX5ikigkLg5WPQfzUXQYOUILdSror-zmrhju6z6gUSc0\/w508-h640\/blog.08.jpg\" width=\"508\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Ch2 style=\"text-align: left;\"\u003ENext Steps\u003C\/h2\u003E\u003Cdiv\u003E\u003Cul style=\"text-align: left;\"\u003E\u003Cli\u003EInstalling the OS\u003C\/li\u003E\u003Cli\u003ESetting up Dropbear so the encrypted root can be unlocked headless\u003C\/li\u003E\u003Cli\u003ESetting up encrypted data partitions\u003C\/li\u003E\u003Cli\u003EConfiguring Nextcloud\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv\u003EStay tuned...\u003C\/div\u003E\u003C\/div\u003E"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/8049446503363794677\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2020\/09\/nextcloud-on-arch-linux-encrypted.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/8049446503363794677"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/8049446503363794677"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2020\/09\/nextcloud-on-arch-linux-encrypted.html","title":"Nextcloud on Arch Linux (Encrypted System) [Part 01 - Preparation]"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEg71wE2wfWQQ9cNUPp9YUGvvJcpnSyElF3wIbOAg5jt8pBElZQ2JgOWrzdldPdbK1BcqBNLrn0PxxrrOFFRQlm1ffiECb1MJhiKeu862HrPjZYgUzS1WMqHPnTBxpybOK9O-ARZy7YWV-Q\/s72-w612-h640-c\/network.jpg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-1136148174127275583"},"published":{"$t":"2020-09-25T18:50:00.001-07:00"},"updated":{"$t":"2020-09-25T18:50:44.040-07:00"},"title":{"type":"text","$t":"Arch Linux install on XP3 13 9300 (Dual boot with Windows 10)"},"content":{"type":"html","$t":"\u003Cp\u003E\u0026nbsp;In this article I will detail the steps I followed to install Arch Linux in my XPS 13 9300. It came with windows 10 preinstalled.\u0026nbsp;\u003C\/p\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEioLITtkc5ymzyn1iyoHzD0Tjx4bCQIuPmjMXmHji6F8RoMxs-HqDg5PF8UMjC044q4T9e6muYQe8ZKrq5AECg7pXLP8a90dZs1oRaY3zA0saRCUWfcRz3MbvkRHhck8TT4I9bK0wqh3tc\/s740\/archey3.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"353\" data-original-width=\"740\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEioLITtkc5ymzyn1iyoHzD0Tjx4bCQIuPmjMXmHji6F8RoMxs-HqDg5PF8UMjC044q4T9e6muYQe8ZKrq5AECg7pXLP8a90dZs1oRaY3zA0saRCUWfcRz3MbvkRHhck8TT4I9bK0wqh3tc\/s16000\/archey3.jpg\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cbr \/\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EMy main references during the process was,\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ca href=\"https:\/\/wiki.archlinux.org\/index.php\/installation_guide\" target=\"_blank\"\u003Einstallation_guide [Arch Wiki]\u003C\/a\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Ca href=\"https:\/\/wiki.archlinux.org\/index.php\/Dell_XPS_13_(9300)\" target=\"_blank\"\u003EDell_XPS_13_(9300) [Arch Wiki]\u003C\/a\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Ch2 style=\"text-align: left;\"\u003EPrerequisites\u003C\/h2\u003E\u003Cdiv style=\"text-align: justify;\"\u003EThe first step is to change the SATA mode from \"RAID\" to \"AHCI\". Without doing this the linux installation can't identify the nvme drive on the laptop. This involves going through a few steps.\u0026nbsp;\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cul\u003E\u003Cli\u003EBoot into windows and run the following commands\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003EOpen a command promt with admin rights\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #999999;\"\u003Ebcdedit \/set {current} safeboot minimal\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003ERestart into bios and change the SATA mode to AHCI\u003C\/li\u003E\u003Cli\u003EBoot back to windows and open an admin command prompt\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ebcdedit \/deletevalue {current} safeboot\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003EWhen I run\u0026nbsp;\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ebcdedit \/enum\u003C\/span\u003E\u003C\/i\u003E it doesn't show \"safeboot\" any more.\u0026nbsp;\u003C\/li\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjBl3v0_reVjm8eQwHCzkpkbtSlg2sNb5pU8AL8SL4U-iWCEUTfUueFCBQU_uMDWppyHbZR-YRNggPPIZutEe-NxmJwyRKLNexpdWYMRX8PPTyuAj31xmmjt6n_KyplEZ8X-HzFZMTu4I0\/s687\/bcdedit.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"687\" data-original-width=\"527\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjBl3v0_reVjm8eQwHCzkpkbtSlg2sNb5pU8AL8SL4U-iWCEUTfUueFCBQU_uMDWppyHbZR-YRNggPPIZutEe-NxmJwyRKLNexpdWYMRX8PPTyuAj31xmmjt6n_KyplEZ8X-HzFZMTu4I0\/s16000\/bcdedit.jpg\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cbr \/\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003ENext I disabled fast startup on windows. I'm doing this as I may choose to mount windows partitions when I'm on linux, and fast startup setting is known to cause issues. I only use windows to play games which I rarely do these days. So windows taking a few more seconds to load is not of a concern to me. So I disabled fast startup via \"Power and sleep settings\" -\u0026gt; \"Additional power settings\"\u003C\/div\u003E\u003Ch2\u003EInstallation media\u003C\/h2\u003E\u003Cdiv\u003ENext I prepared an Arch installation media. I did this on my old laptop which was running Ubuntu. I used \u003Ca href=\"https:\/\/www.balena.io\/etcher\/\"\u003EEtcher\u003C\/a\u003E to write the downloaded iso to a USB. I also used the following commands to verify the integrity of the downloaded iso file.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Esha1sum archlinux-2020.08.01-x86_64.iso\u0026nbsp;\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emd5sum archlinux-2020.08.01-x86_64.iso\u003C\/span\u003E\u003C\/i\u003E\u0026nbsp;\u003C\/li\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003C\/div\u003E\u003Ch2\u003EPartitioning\u003C\/h2\u003E\u003Cdiv\u003EAfter that I booted into the Arch install usb media. At this point I have a custom windows installation with an already shrinked C drive. If not I would have booted into an Ubuntu installation media and used \"\u003Ca href=\"https:\/\/gparted.org\/\"\u003Egparted\u003C\/a\u003E\" to resize the partitions.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EI used \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Elsblk\u003C\/span\u003E\u003C\/i\u003E command to identify my current partition structure.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u0026nbsp;\u0026nbsp;\u0026nbsp;\u003C\/div\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgKeMhDaHBHaURHw57wjqxhLOwDsSXU037xsbqUS1qrKl1GsACIwFE6aOva-ZBYASgpJfK_2_EhGHVit3qO7pGOx0p18To4DCCiEFvhmLKgHDERHR5OfgwhEWz4sZ4RiKe0lxsYOjLNZkM\/s538\/lsblk1.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"320\" data-original-width=\"538\" height=\"380\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgKeMhDaHBHaURHw57wjqxhLOwDsSXU037xsbqUS1qrKl1GsACIwFE6aOva-ZBYASgpJfK_2_EhGHVit3qO7pGOx0p18To4DCCiEFvhmLKgHDERHR5OfgwhEWz4sZ4RiKe0lxsYOjLNZkM\/w640-h380\/lsblk1.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003EThe \"nvme0n1p5\" is the windows recovery partition and \"nvme0n1p6\" is my windows data partition. I'm going to delete both these partitions to gain space for the Arch installation. This is done with \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Efdisk\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Efdisk nvme0n1\u003C\/span\u003E\u003C\/i\u003E - This took me into the fdisk mode for the nvme drive\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ed\u003C\/span\u003E\u003C\/i\u003E and press \"Enter\" to accept to delete the last partition\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ed\u003C\/span\u003E\u003C\/i\u003E and press \"Enter\" again to delete one more partition from the bottom\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003En\u003C\/span\u003E\u003C\/i\u003E and press \"Enter\" to create a new partition\u003C\/li\u003E\u003Cli\u003E\"\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E+512M\u003C\/span\u003E\u003C\/i\u003E\" to specify the new partition size. I'm going to use this as my efi boot partition\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Et\u003C\/span\u003E\u003C\/i\u003E to change partition type\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E1\u003C\/span\u003E\u003C\/i\u003E to choose \"EFI\" as the partition type\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003En\u003C\/span\u003E\u003C\/i\u003E again to create the Arch installation partition\u003C\/li\u003E\u003Cli\u003E\"\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E+50G\u003C\/span\u003E\u003C\/i\u003E\" to specify size\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003En\u003C\/span\u003E\u003C\/i\u003E again to create the home partition followed by two \"Enter\" keys to accept the remaining space for the home partition\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ep\u003C\/span\u003E\u003C\/i\u003E to view what will be created followed by \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ew\u003C\/span\u003E\u003C\/i\u003E to write the changes to disk\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv\u003ENow when I use \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Elsblk\u003C\/span\u003E\u003C\/i\u003E it shows the newly changed partition structure.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEijnO4D2dhN5wNL-slR7GGaEeq42UQtIZ1Eiqks3oKYWaUzBCSR5dpaFZTURdmI1-sy0sfhFCizynfA4QXb6dRxAeipoxcX3_cHxnxyH9y-P5mbDNt3xj044fO4rxSqzYdWf8xYjPRjzns\/s536\/lsblk2.jpg\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"296\" data-original-width=\"536\" height=\"354\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEijnO4D2dhN5wNL-slR7GGaEeq42UQtIZ1Eiqks3oKYWaUzBCSR5dpaFZTURdmI1-sy0sfhFCizynfA4QXb6dRxAeipoxcX3_cHxnxyH9y-P5mbDNt3xj044fO4rxSqzYdWf8xYjPRjzns\/w640-h354\/lsblk2.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Cbr \/\u003E\u003C\/div\u003ENow that I have the partition structure next step was to format them. The UFI boot partition needs to be fat32 and for the rest I used ext4\u003C\/div\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkfs.fat -F32 \/dev\/nvme0n1p5\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkfs.ext4 \/dev\/nvme0n1p6\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkfs.ext4 \/dev\/nvme0n1p7\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv\u003EThen I continued to mount the partitions. The main Arch installation partition is mounted to \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E\/mnt\u003C\/span\u003E\u003C\/i\u003E. Boot and home partitions are mounted to \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E\/mnt\/boot \u003C\/i\u003E\u003C\/span\u003Eand \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E\/mnt\/home\u003C\/span\u003E\u003C\/i\u003E respectively\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emount \/dev\/nvme0n1p6 \/mnt\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emkdir \/mnt\/home\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emount \/dev\/nvme0n1p7 \/mnt\/home\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emkdir \/mnt\/boot\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emount \/dev\/nvme0n1p5 \/mnt\/boot\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003Cdiv\u003EThen I created the fstab file. This is needed to mount these during boot. the \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003E-U\u003C\/i\u003E\u003C\/span\u003E flag was used to identify the partitions using \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003EUUID\u003C\/i\u003E\u003C\/span\u003E instead of labels etc.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Emkdir mnt\/etc\/\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Egenfstab -U \/mnt \u0026gt;\u0026gt; \/mnt\/etc\/fstab\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003C\/div\u003E\u003Ch2\u003EInstalation\u003C\/h2\u003E\u003Cdiv\u003EI have everything ready to start the installation. First step is to establish an internet connection. Arch installation media\u0026nbsp; comes with the\u0026nbsp;\u003Ca href=\"https:\/\/wiki.archlinux.org\/index.php\/Iwd\"\u003EiNet wireless daemon\u003C\/a\u003E. I used it to establish a wifi connection.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eiwctl\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Estation wlan0 connect [SSID]\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003Cdiv\u003EThen I used \u003Ca href=\"https:\/\/jlk.fjfi.cvut.cz\/arch\/manpages\/man\/pacstrap.8\"\u003Epacstrap\u003C\/a\u003E\u0026nbsp;to install the essentials to the previously created new linux root \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E\/mnt\u003C\/span\u003E\u003C\/i\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epacstrap \/mnt base linux linux-firmware linux-headers linux-lts linux-lts-headers\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003Cdiv\u003EThe latest rolling and the long term kernels were installed using the above command. The header files were installed to support additional module builds in the future. I plan to use the latest rolling kernel for the most part but the LTS kernel may come in handy to recover from kernel panics. Now is the time to switch to the newly installed linux instalation from the usb.\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Earch-chroot \/mnt\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003Cdiv\u003EFirst things first. Install my favorite editors\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epacman -Sy vim nano\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003Cdiv\u003ENext is the boring task of configuration. I followed the \u003Ca href=\"https:\/\/wiki.archlinux.org\/index.php\/installation_guide\"\u003EArch wiki\u003C\/a\u003E for this.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003ESet timezone\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Eln -sf \/usr\/share\/zoneinfo\/America\/Los_Angeles \/etc\/localtime\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Ehwclock --systohc\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003ELocal config\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Evim \/etc\/locale.gen\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003EUncomment \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Een_US.UTF-8 UTF-8\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Elocale-gen\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003Evim \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E\/etc.locale-conf\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003EEnter \u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003ELANG=en_US.UTF-8\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003ENetwork config\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Evim \/etc\/hostname\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003Cli\u003EEnter name\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Evim \/etc\/hosts\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003EEnter\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E127.0.0.1 localhost\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E::1 localhost\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E127.0.1.1 [name].localdomain [name]\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003Ch3\u003ECreate users and set passwords\u003C\/h3\u003E\u003C\/div\u003E\u003Cdiv\u003EI used passwd command to set the root password then created an everyday user for myself and set a new password to that user\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Euseradd -g users -G power,storage,wheel -m [name]\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epasswd [name]\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/div\u003E\u003Ch3\u003EBootloader\u003C\/h3\u003E\u003Cdiv\u003ENext I setup grub as the boot-loader. This is the time to get the dual boot with windows setup.\u0026nbsp;\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003EInstall intel processor microcode\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Cspan style=\"color: #666666;\"\u003E\u003Ci\u003Epacman -S intel-ucode\u003C\/i\u003E\u003C\/span\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003EInstall grub and util packages\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epacman -S grub efibootmgr mtools\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003EInstall os-prober and identify Windows\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epacman -S os-prober\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eos-prober\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003EInstall grub\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Egrub-install --target=x86_64-efi --efi-directory=\/boot\/ --bootloader-id=GRUB\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003EMount the windows efi boot partition to a temporary location\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkdir \/mntwin\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emount \/dev\/nvme0n1p2 \/mntwin\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Egrub-mkconfig -o \/boot\/grub\/grub.cfg\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003Cdiv\u003EThe output confirms that the windows installation was identified.\u0026nbsp;\u003C\/div\u003E\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhJd2kBPjmWutuUe1sqLLLcuvW9VMpr3MTsavqZVIaedbruxD0v96JooG2svjdv2qqwF1XL0Gy4AHD33qyzgRp1aXitmVHRNE9Op4bc8qLWc8VMSmStLQmwDb0vMJyBCVOgCyTKVo083G8\/s941\/grub.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"591\" data-original-width=\"941\" height=\"402\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhJd2kBPjmWutuUe1sqLLLcuvW9VMpr3MTsavqZVIaedbruxD0v96JooG2svjdv2qqwF1XL0Gy4AHD33qyzgRp1aXitmVHRNE9Op4bc8qLWc8VMSmStLQmwDb0vMJyBCVOgCyTKVo083G8\/w640-h402\/grub.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\u003Cbr \/\u003E\u003Ch3 style=\"text-align: justify;\"\u003ESetup continues...\u003C\/h3\u003E\u003Cdiv style=\"text-align: justify;\"\u003E\u003Cul\u003E\u003Cli\u003EInstall network tools and enable network related \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Esystemd\u003C\/span\u003E\u003C\/i\u003E services\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epacman -S base-devel networkmanager dialog\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Esystemctl enable NetworkManager\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Esystemctl enable systemd-networkd.service\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Esystemctl enable systemd-resolved.service\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003ECreate initial ramdisk for the kernels\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkinitcpio -p linux\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkinitcpio -p linux-lts\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003EInstall and configure sudo\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003EInstall sudo and configure so that any user in the wheel group can elevate themselves after confirming the password\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epacman -S sudo\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Evisudo\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003EUncomment and change wheel access line to \u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003E%wheel ALL=(ALL) ALL\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003ECreate swap file\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Edd if=\/dev\/zero of=\/swapfile bs=1M count=8192 status=progress\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Echmod 600 \/swapfile\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Emkswap \/swapfile\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eecho '\/swapfile none swap 0 0' | tee -a \/etc\/fstab\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cli\u003EReboot into the new system\u003C\/li\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eexit\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Eumount -a\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Ereboot\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003C\/ul\u003E\u003Ch2\u003EGnome Install\u003C\/h2\u003E\u003Cdiv\u003EAmazeballs! Now that I have a working arch system I'm gonna install my favorite desktop environment. First I need to connect and setup wifi\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Enmcli device wifi connect GasGembi password [password]\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv\u003EThen install Gnome\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Epacman -S xorg-server\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Esudo pacman -S gnome gnome-extra\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Esudo systemctl enable gdm\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv\u003EAfter rebooting I was greeted with the Gnome login screen. :)\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003EOne last thing. Setting up bluetooth\u003C\/div\u003E\u003Cdiv\u003E\u003Cul\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Elsmod | grep btusb\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Esudo pacman -S bluez bluez-utils\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003Cli\u003E\u003Ci\u003E\u003Cspan style=\"color: #666666;\"\u003Esystemctl enable bluetooth.service\u003C\/span\u003E\u003C\/i\u003E\u003C\/li\u003E\u003C\/ul\u003E\u003Cdiv\u003EThe XPS 13 9300 also comes with a fingerprint sensor and windows hello capabilities. I will address the linux setup for these in a future post.\u0026nbsp;\u003C\/div\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003Cdiv\u003E\u003Cbr \/\u003E\u003C\/div\u003E\u003C\/div\u003E\u003Cp\u003E\u003Cbr \/\u003E\u003C\/p\u003E\u003Cp\u003E\u003C\/p\u003E"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/1136148174127275583\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2020\/09\/arch-linux-install-on-xp3-13-9300-dual.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/1136148174127275583"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/1136148174127275583"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2020\/09\/arch-linux-install-on-xp3-13-9300-dual.html","title":"Arch Linux install on XP3 13 9300 (Dual boot with Windows 10)"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEioLITtkc5ymzyn1iyoHzD0Tjx4bCQIuPmjMXmHji6F8RoMxs-HqDg5PF8UMjC044q4T9e6muYQe8ZKrq5AECg7pXLP8a90dZs1oRaY3zA0saRCUWfcRz3MbvkRHhck8TT4I9bK0wqh3tc\/s72-c\/archey3.jpg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-2906638488682766869"},"published":{"$t":"2020-01-21T21:13:00.001-08:00"},"updated":{"$t":"2020-01-21T21:13:59.852-08:00"},"title":{"type":"text","$t":"Hello SMB, bye bye OMV"},"content":{"type":"html","$t":"\u003Cdiv style=\"text-align: justify;\"\u003E\nIn this post I'm going to talk about why and how I moved my Open Media Vault Network Attached Storage system to my Ubuntu server.\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nIn my \u003Ca href=\"https:\/\/blog.coding-mayhem.com\/2019\/11\/nas-build-part-iii.html\" target=\"_blank\"\u003Eprevious posts\u003C\/a\u003E I talked about my OMV NAS. In my NAS I have the following things set up.\u003C\/div\u003E\n\u003Cul\u003E\n\u003Cli\u003ESMB shares that can be accessed by both Windows and Linux machines\u003C\/li\u003E\n\u003Cli\u003ECustom user permissions\u003C\/li\u003E\n\u003Cli\u003EAutomated backups\u003C\/li\u003E\n\u003Cli\u003EMetrics and logging\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nIn an attempt to be more frugal I'm trying to reduce the number of servers in my home lab. So lets talk through the process of moving this setup from OMV to Ubuntu.\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nWhat's involved,\u0026nbsp;\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cul\u003E\n\u003Cli\u003EMounting multiple USB hard disks to the same location between reboots\u003C\/li\u003E\n\u003Cli\u003ECreating samba shares in Ubuntu server\u003C\/li\u003E\n\u003Cli\u003ESetting up SMB in windows client machines\u003C\/li\u003E\n\u003Cli\u003EAutomated backups using \u003Ca href=\"https:\/\/linux.die.net\/man\/1\/rsync\" target=\"_blank\"\u003Ersync\u003C\/a\u003E and \u003Ca href=\"http:\/\/manpages.ubuntu.com\/manpages\/trusty\/man8\/cron.8.html\" target=\"_blank\"\u003Ecron\u003C\/a\u003E\u003C\/li\u003E\n\u003Cli\u003EMetrics and logging\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cdiv\u003E\n\u003Ch2\u003E\n\u003Cu\u003EMounting USB drives permanently\u003C\/u\u003E\u003C\/h2\u003E\nAs mentioned \u003Ca href=\"https:\/\/blog.coding-mayhem.com\/2019\/11\/nas-build-part-ii.html\" target=\"_blank\"\u003Ebefore\u003C\/a\u003E I'm using 3 USB hard drives for storage and backup. These USB drives needs to be mounted to the same location all the time because the SMB shares and the rsync cron job depends on it.\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\nEvery disk partition has a unique identifier which is called an UUID. Firstly you need to find this. This can be found using the \u003Ca href=\"https:\/\/linux.die.net\/man\/8\/blkid\" target=\"_blank\"\u003Eblkid\u003C\/a\u003E command. You may or may not need super user permissions to do this. You can issue \"\u003Cb\u003E\u003Ci\u003Esudo blkid\u003C\/i\u003E\u003C\/b\u003E\" command to find the UUID of all drives connected to your system or you can issue \"\u003Cb\u003E\u003Ci\u003Esudo blkid\u0026nbsp;[device ...]\u003C\/i\u003E\u003C\/b\u003E\" to find the UUID or a specific partition.\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEiZKNXvtbGDGV_qCtp36TdQ9YWO57gWcswPJV7_T8qngOvG41BSk-zskfn_xgspQ-juGyyTYEvJpWc_FjsUCmBlJOZqUkd4wapSHsJkpGWrmRsID0OO554sGpBDiHPiTFqfSSinvQDLDKM\/s1600\/Screenshot+from+2020-01-19+16-57-30.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"314\" data-original-width=\"1600\" height=\"125\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEiZKNXvtbGDGV_qCtp36TdQ9YWO57gWcswPJV7_T8qngOvG41BSk-zskfn_xgspQ-juGyyTYEvJpWc_FjsUCmBlJOZqUkd4wapSHsJkpGWrmRsID0OO554sGpBDiHPiTFqfSSinvQDLDKM\/s640\/Screenshot+from+2020-01-19+16-57-30.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\nArmed with the UUIDs of my USB disk drives I created mount points for them. I created 3 folders in \"\/media\" as mount points for the disks. Next step is to add entries into the file system tables or\u0026nbsp;\u003Ca href=\"https:\/\/help.ubuntu.com\/community\/Fstab\" target=\"_blank\"\u003Efstab\u003C\/a\u003E. Fstab is generally used to mount internal devices. In my use case though I'm going to use fstab to permanently mount my USB disk drives. The syntax for the \u003Ca href=\"https:\/\/help.ubuntu.com\/community\/Fstab\" target=\"_blank\"\u003Efstab\u003C\/a\u003E is,\u003C\/div\u003E\n\u003Cpre style=\"background-color: #f3f3f3; border: 1px dashed rgb(193, 180, 150); color: #333333; font-family: UbuntuMono, courier, monospace; font-size: 13px; line-height: 16px; margin-bottom: 8px; overflow-wrap: break-word; padding: 4pt; text-align: left; vertical-align: baseline; white-space: pre-wrap;\"\u003E[Device] [Mount Point] [File System Type] [Options] [Dump] [Pass]\u003C\/pre\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\nI added 3 new entries to my \u003Cb\u003E\u003Ci\u003E\/etc\/fstab\u003C\/i\u003E\u003C\/b\u003E file.\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEi7Snr97Chl0WZJ4scvZzuG0XBN3qzu5GCRWCd-i3_jdRUTsyRtK4V_XW_BLQmgm0X17cJvwmYN2gb2dAY96n9uZrJ6MPa_S2XB9_eYe4MMUx_JrbnByOet5qg1704kL3EsHOdi0pWEIY8\/s1600\/Screenshot+from+2020-01-19+18-25-30.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"468\" data-original-width=\"1590\" height=\"188\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEi7Snr97Chl0WZJ4scvZzuG0XBN3qzu5GCRWCd-i3_jdRUTsyRtK4V_XW_BLQmgm0X17cJvwmYN2gb2dAY96n9uZrJ6MPa_S2XB9_eYe4MMUx_JrbnByOet5qg1704kL3EsHOdi0pWEIY8\/s640\/Screenshot+from+2020-01-19+18-25-30.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\nI validated the disk mount locations with a few restarts.\u0026nbsp;\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Ch2 style=\"clear: both; text-align: justify;\"\u003E\n\u003Cu\u003ECreating Samba shares\u003C\/u\u003E\u003C\/h2\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\nI installed samba into my Ubuntu server using the \u003Ca href=\"https:\/\/help.ubuntu.com\/lts\/serverguide\/apt.html\" target=\"_blank\"\u003Eapt\u003C\/a\u003E tool\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003C\/div\u003E\n\u003Cpre style=\"background-color: #f3f3f3; border: 1px dashed rgb(193, 180, 150); color: #333333; font-family: UbuntuMono, courier, monospace; font-size: 13px; line-height: 16px; margin-bottom: 8px; overflow-wrap: break-word; padding: 4pt; text-align: left; vertical-align: baseline; white-space: pre-wrap;\"\u003Esudo apt install samba\u003C\/pre\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\nNext comes the user management. Firstly I created the users I need in my Ubuntu server using the \"\u003Ca href=\"https:\/\/linux.die.net\/man\/8\/adduser\" target=\"_blank\"\u003Eadduser\u003C\/a\u003E\" command. Samba doesn't use system passwords so I needed to set the samba passwords for the users.\u003C\/div\u003E\n\u003Cpre style=\"background-color: #f3f3f3; border: 1px dashed rgb(193, 180, 150); color: #333333; font-family: UbuntuMono, courier, monospace; font-size: 13px; line-height: 16px; margin-bottom: 8px; overflow-wrap: break-word; padding: 4pt; text-align: left; vertical-align: baseline; white-space: pre-wrap;\"\u003Esudo adduser user1\nsudo smbpasswd -a user1\u003C\/pre\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\nThen I created the samba shares. The configuration has to be added to the \"\u003Cb\u003E\u003Ci\u003E\/etc\/samba\/smb.conf\u003C\/i\u003E\u003C\/b\u003E\".\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEg5rdY5SSrpdxeVPFVn2yndDkYun6lRawX3327s7zut7QSdgl5pVqoztttbd1rnpHs_DX1B42jAdOPC6lH5DYhcwMB0lC-52nMyHx-DiZC9-Yt4lKeX21tr3NG3u-7nXB4XLxveym_2G-I\/s1600\/Screenshot+from+2020-01-20+08-47-21.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1022\" data-original-width=\"1496\" height=\"436\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEg5rdY5SSrpdxeVPFVn2yndDkYun6lRawX3327s7zut7QSdgl5pVqoztttbd1rnpHs_DX1B42jAdOPC6lH5DYhcwMB0lC-52nMyHx-DiZC9-Yt4lKeX21tr3NG3u-7nXB4XLxveym_2G-I\/s640\/Screenshot+from+2020-01-20+08-47-21.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\nGiven above is the config for one of the shared folders. The configuration follows the samba \u003Ca href=\"https:\/\/www.samba.org\/samba\/docs\/current\/man-html\/smb.conf.5.html\" target=\"_blank\"\u003Esyntax\u003C\/a\u003E. Linux permissions proceed samba permissions so the folders shared has less restrictive permissions to allow \"others\" category read, write and execute permissions as needed.\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Ch2 style=\"clear: both; text-align: justify;\"\u003E\n\u003Cu\u003EEnabling Samba in Windows\u003C\/u\u003E\u003C\/h2\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\nIn order for the windows machines in my network to view the samba shares I had to enable the SMB file sharing support windows feature.\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhELdfFWB6B2ia8HnakeAUTUaYGv-hn8QgLHw-P_NIiRbSn04HyfA-8uCaSdv8_EnKiox2xaqzBPzecBA3KLujnrtW7bReKtdJabMNkYBaSMBESmjR7AUMiOHHsUuHXD7iN_SpmebagYXQ\/s1600\/cfs.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"769\" data-original-width=\"1515\" height=\"324\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhELdfFWB6B2ia8HnakeAUTUaYGv-hn8QgLHw-P_NIiRbSn04HyfA-8uCaSdv8_EnKiox2xaqzBPzecBA3KLujnrtW7bReKtdJabMNkYBaSMBESmjR7AUMiOHHsUuHXD7iN_SpmebagYXQ\/s640\/cfs.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\nI will talk about the backups and logging in a future post. Stay tuned ...\u003C\/div\u003E\n\u003C\/div\u003E\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/2906638488682766869\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2020\/01\/hello-smb-bye-bye-omv.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/2906638488682766869"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/2906638488682766869"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2020\/01\/hello-smb-bye-bye-omv.html","title":"Hello SMB, bye bye OMV"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEiZKNXvtbGDGV_qCtp36TdQ9YWO57gWcswPJV7_T8qngOvG41BSk-zskfn_xgspQ-juGyyTYEvJpWc_FjsUCmBlJOZqUkd4wapSHsJkpGWrmRsID0OO554sGpBDiHPiTFqfSSinvQDLDKM\/s72-c\/Screenshot+from+2020-01-19+16-57-30.jpg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-830349664271273779"},"published":{"$t":"2019-12-26T20:29:00.000-08:00"},"updated":{"$t":"2019-12-29T18:40:04.483-08:00"},"title":{"type":"text","$t":"Intel vs AMD for my server"},"content":{"type":"html","$t":"This is a continuation of my previous post \"\u003Ca href=\"https:\/\/blog.coding-mayhem.com\/2019\/11\/building-server-from-scratch.html\"\u003EBuilding a Server from scratch\u003C\/a\u003E\"\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ctable align=\"center\" cellpadding=\"0\" cellspacing=\"0\" class=\"tr-caption-container\" style=\"margin-left: auto; margin-right: auto; text-align: center;\"\u003E\u003Ctbody\u003E\n\u003Ctr\u003E\u003Ctd style=\"text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgIJGT5ulr3YbWZHJAXqokoNqjdwoPW4rV_jIpWmLja03I2oehOwAERFb_yTai1Io-RjtPS6LUn8wy6ymBX8pjetAupFN739MXEVX7Vd38uRYxOqgESS6zWwM6bXKgoOFSK-F6DwdIL3v0\/s1600\/IMG_7188.jpg\" imageanchor=\"1\" style=\"margin-left: auto; margin-right: auto;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1600\" data-original-width=\"1521\" height=\"640\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgIJGT5ulr3YbWZHJAXqokoNqjdwoPW4rV_jIpWmLja03I2oehOwAERFb_yTai1Io-RjtPS6LUn8wy6ymBX8pjetAupFN739MXEVX7Vd38uRYxOqgESS6zWwM6bXKgoOFSK-F6DwdIL3v0\/s640\/IMG_7188.jpg\" width=\"608\" \/\u003E\u003C\/a\u003E\u003C\/td\u003E\u003C\/tr\u003E\n\u003Ctr\u003E\u003Ctd class=\"tr-caption\" style=\"text-align: center;\"\u003EMy XPS 12 9q33This Too Shall Pass\u003C\/td\u003E\u003C\/tr\u003E\n\u003C\/tbody\u003E\u003C\/table\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nLets recap. In my \u003Ca href=\"https:\/\/blog.coding-mayhem.com\/2019\/11\/building-server-from-scratch.html\"\u003Eprevious\u003C\/a\u003E post I narrowed down the processor (\u003Ca href=\"https:\/\/ark.intel.com\/content\/www\/us\/en\/ark\/products\/134870\/intel-core-i3-9100-processor-6m-cache-up-to-4-20-ghz.html\"\u003Ei3-9100\u003C\/a\u003E), chipset (\u003Ca href=\"https:\/\/ark.intel.com\/content\/www\/us\/en\/ark\/products\/133332\/intel-b360-chipset.html\"\u003EB360\u003C\/a\u003E) and the motherboard (\u003Ca href=\"https:\/\/www.asus.com\/us\/Motherboards\/PRIME-B360M-A\/\"\u003EAsus B360M-A\u003C\/a\u003E) I would use if I'm going with an Intel system. This would cost me about $235. In this post I'll be looking at a comparable AMD system. The main areas I'll be covering in this post are,\u003Cbr \/\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\"https:\/\/www.blogger.com\/blogger.g?blogID=9001854664565320016#ancProcessor\"\u003EAMD processor discovery\u003C\/a\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Ca href=\"https:\/\/www.blogger.com\/blogger.g?blogID=9001854664565320016#ancCompProc\"\u003ECompare the chosen AMD processor with the Intel one\u003C\/a\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Ca href=\"https:\/\/www.blogger.com\/blogger.g?blogID=9001854664565320016#ancChip\"\u003EChipset and Motherboard options for the chosen AMD processor\u003C\/a\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Ca href=\"https:\/\/www.blogger.com\/blogger.g?blogID=9001854664565320016#ancMem\"\u003EChoosing the right memory for my server\u003C\/a\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Ca href=\"https:\/\/www.blogger.com\/blogger.g?blogID=9001854664565320016#ancPsu\"\u003EHow to choose a power supply unit\u003C\/a\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Ca href=\"https:\/\/www.blogger.com\/blogger.g?blogID=9001854664565320016#ancHdd\"\u003EHard drive selection\u003C\/a\u003E\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Ca href=\"https:\/\/www.blogger.com\/null\" name=\"ancProcessor\"\u003E\u003C\/a\u003EGenerally when comparing processors, the main parameters to look at are;\u003Cbr \/\u003E\n\u003Cul\u003E\n\u003Cli\u003EClock speed\u003C\/li\u003E\n\u003Cli\u003ENumber of cores\u003C\/li\u003E\n\u003Cli\u003ENumber of threads\u003C\/li\u003E\n\u003Cli\u003ELocked \/ Unlocked ( in other words is it overclockable )\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cdiv\u003E\nIn this instance since, my requirement is to run a server on this hardware and the number of cores becomes the major deciding factor. Cause having more cores will improve my virtualization options.\u003C\/div\u003E\n\u003Cbr \/\u003E\nAMD \u003Ca href=\"https:\/\/www.amd.com\/en\/processors\/athlon-and-a-series#ATHLON\"\u003EAthlon\u003C\/a\u003E\u0026nbsp;and\u0026nbsp;\u003Ca href=\"https:\/\/www.amd.com\/en\/ryzen-3\"\u003ERyzen 3\u003C\/a\u003E are comfortably within my price range and some \u003Ca href=\"https:\/\/www.amd.com\/en\/ryzen-5\"\u003ERyzen 5\u003C\/a\u003E processors also fall within my budget.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ca href=\"https:\/\/www.amd.com\/en\/processors\/athlon-and-a-series#ATHLON\"\u003EAthlon\u003C\/a\u003E\u0026nbsp;\u0026nbsp;processors has 2 cores and 4 threads. The price is very competitive. Compared to the 4 cores and 4 threads of my selected Intel processor they don't look very attractive to me even at their price point.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ca href=\"https:\/\/www.amd.com\/en\/ryzen-3\"\u003ERyzen 3\u003C\/a\u003E\u0026nbsp;processors has 4 cores and can run 4 threads. There are 2 options. \u003Ca href=\"https:\/\/www.amd.com\/en\/products\/apu\/amd-ryzen-3-2200g\"\u003ERyzen 3 2200g\u003C\/a\u003E and \u003Ca href=\"https:\/\/www.amd.com\/en\/products\/apu\/amd-ryzen-3-3200g\"\u003ERyzen 3 3200g\u003C\/a\u003E.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ca href=\"https:\/\/www.amd.com\/en\/ryzen-5\"\u003ERyzen 5\u003C\/a\u003E processors has a 6 core and 4 core varieties that can run 12 and 8 threads respectively. All these are better options for my use case rather than the\u0026nbsp;\u003Ca href=\"https:\/\/ark.intel.com\/content\/www\/us\/en\/ark\/products\/134870\/intel-core-i3-9100-processor-6m-cache-up-to-4-20-ghz.html\"\u003Ei3-9100\u003C\/a\u003E. When considering the price, 2 of these processors which has 4 cores and 8 threads with integrated graphics is within the price range. They are the \u003Ca href=\"https:\/\/www.amd.com\/en\/products\/apu\/amd-ryzen-5-2400g\"\u003ERyzen 5 2400g\u003C\/a\u003E and \u003Ca href=\"https:\/\/www.amd.com\/en\/products\/apu\/amd-ryzen-5-3400g\"\u003ERyzen 5 3400g\u003C\/a\u003E.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nWhen looking at the Ryzen 5 models without discrete graphics I need to consider the possible cost of a low end graphics card. The \u003Ca href=\"https:\/\/www.amd.com\/en\/products\/cpu\/amd-ryzen-5-2600\"\u003ERyzen 5 2600\u003C\/a\u003E, and the \u003Ca href=\"https:\/\/www.amd.com\/en\/products\/cpu\/amd-ryzen-5-2600x\"\u003ERyzen5 2600X\u003C\/a\u003E seem like possible candidates in this sector. \n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nLet's have a closer look at these 6 processors.\u003C\/div\u003E\n\u003Cstyle\u003E\n        .compare_table table {\n            border-collapse: collapse;\n            table-layout: fixed;\n            width: 900px;\n        }\n        .compare_table th,\n        .compare_table td {\n            padding: 15px;\n            border: 1px solid green;\n            width: 100px;\n        }\n    \u003C\/style\u003E\n    \u003Cbr \/\u003E\n\u003Ctable align=\"center\" class=\"compare_table\"\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n  \u003Cth colspan=\"2\"\u003E\u003C\/th\u003E\n  \u003Cth\u003ERyzen 3 2200g\u003C\/th\u003E\n  \u003Cth\u003ERyzen 3 3200g\u003C\/th\u003E\n  \u003Cth\u003ERyzen 5 2400g\u003C\/th\u003E\n  \u003Cth\u003ERyzen 5 3400g\u003C\/th\u003E\n  \u003Cth\u003ERyzen 5 2600\u003C\/th\u003E\n  \u003Cth\u003ERyzen 5 2600x\u003C\/th\u003E\n \u003C\/tr\u003E\n\u003C\/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n \u003Ctd colspan=\"2\"\u003E\u003Cstrong\u003E# cores\u003C\/strong\u003E\u003C\/td\u003E\n \u003Ctd\u003E4\u003C\/td\u003E\n \u003Ctd\u003E4\u003C\/td\u003E\n \u003Ctd\u003E4\u003C\/td\u003E\n \u003Ctd\u003E4\u003C\/td\u003E\n \u003Ctd\u003E6\u003C\/td\u003E\n \u003Ctd\u003E6\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n \u003Ctd colspan=\"2\"\u003E\u003Cstrong\u003E# threads\u003C\/strong\u003E\u003C\/td\u003E\n \u003Ctd\u003E4\u003C\/td\u003E\n \u003Ctd\u003E4\u003C\/td\u003E\n \u003Ctd\u003E8\u003C\/td\u003E\n \u003Ctd\u003E8\u003C\/td\u003E\n \u003Ctd\u003E12\u003C\/td\u003E\n \u003Ctd\u003E12\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n \u003Ctd colspan=\"7\"\u003EClock speed\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n \u003Ctd\u003E\u003C\/td\u003E\n \u003Ctd\u003E\u003Cstrong\u003EBase\u003C\/strong\u003E\u003C\/td\u003E\n \u003Ctd\u003E3.5GHz\u003C\/td\u003E\n \u003Ctd\u003E3.6GHz\u003C\/td\u003E\n \u003Ctd\u003E3.6GHz\u003C\/td\u003E\n \u003Ctd\u003E3.7GHz\u003C\/td\u003E\n \u003Ctd\u003E3.4GHz\u003C\/td\u003E\n \u003Ctd\u003E3.6GHz\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n \u003Ctd\u003E\u003C\/td\u003E\n \u003Ctd\u003E\u003Cstrong\u003EBoost\u003C\/strong\u003E\u003C\/td\u003E\n \u003Ctd\u003E3.7GHz\u003C\/td\u003E\n \u003Ctd\u003E4GHz\u003C\/td\u003E\n \u003Ctd\u003E3.9GHz\u003C\/td\u003E\n \u003Ctd\u003E4.2GHz\u003C\/td\u003E\n \u003Ctd\u003E3.9GHz\u003C\/td\u003E\n \u003Ctd\u003E4.2GHz\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n \u003Ctd colspan=\"7\"\u003ECache\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n \u003Ctd\u003E\u003C\/td\u003E\n \u003Ctd\u003EL1\u003C\/td\u003E\n \u003Ctd\u003E384KB\u003C\/td\u003E\n \u003Ctd\u003E384KB\u003C\/td\u003E\n \u003Ctd\u003E384KB\u003C\/td\u003E\n \u003Ctd\u003E384KB\u003C\/td\u003E\n \u003Ctd\u003E576KB\u003C\/td\u003E\n \u003Ctd\u003E576KB\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n \u003Ctd\u003E\u003C\/td\u003E\n \u003Ctd\u003EL2\u003C\/td\u003E\n \u003Ctd\u003E2MB\u003C\/td\u003E\n \u003Ctd\u003E2MB\u003C\/td\u003E\n \u003Ctd\u003E2MB\u003C\/td\u003E\n \u003Ctd\u003E2MB\u003C\/td\u003E\n \u003Ctd\u003E3MB\u003C\/td\u003E\n \u003Ctd\u003E3MB\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n \u003Ctd\u003E\u003C\/td\u003E\n \u003Ctd\u003EL3\u003C\/td\u003E\n \u003Ctd\u003E4MB\u003C\/td\u003E\n \u003Ctd\u003E4MB\u003C\/td\u003E\n \u003Ctd\u003E4MB\u003C\/td\u003E\n \u003Ctd\u003E4MB\u003C\/td\u003E\n \u003Ctd\u003E16MB\u003C\/td\u003E\n \u003Ctd\u003E16MB\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n \u003Ctd colspan=\"2\"\u003E\u003Cstrong\u003EIntegrated Graphics\u003C\/strong\u003E\u003C\/td\u003E\n \u003Ctd\u003Eyes\u003C\/td\u003E\n \u003Ctd\u003Eyes\u003C\/td\u003E\n \u003Ctd\u003Eyes\u003C\/td\u003E\n \u003Ctd\u003Eyes\u003C\/td\u003E\n \u003Ctd\u003Eno\u003C\/td\u003E\n \u003Ctd\u003Eno\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003C\/tbody\u003E\n\u003C\/table\u003E\n\u003Cbr \/\u003E\nWe have a clear winner here. The\u0026nbsp;\u003Ca href=\"https:\/\/www.amd.com\/en\/products\/cpu\/amd-ryzen-5-2600\" style=\"text-align: justify;\"\u003ERyzen 5 2600x\u003C\/a\u003E.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ca href=\"https:\/\/www.blogger.com\/null\" name=\"ancCompProc\"\u003E\u003C\/a\u003ELets compare the\u0026nbsp;\u003Ca href=\"https:\/\/www.amd.com\/en\/products\/cpu\/amd-ryzen-5-2600x\" style=\"text-align: justify;\"\u003ERyzen 5 2600x\u003C\/a\u003E\u0026nbsp;and the\u0026nbsp;\u003Ca href=\"https:\/\/ark.intel.com\/content\/www\/us\/en\/ark\/products\/134870\/intel-core-i3-9100-processor-6m-cache-up-to-4-20-ghz.html\" style=\"text-align: justify;\"\u003Ei3-9100\u003C\/a\u003E\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ctable align=\"center\" class=\"compare_table\"\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n  \u003Cth colspan=\"2\"\u003E\u003C\/th\u003E\n  \u003Cth\u003ERyzen 5 2600x\u003C\/th\u003E\n  \u003Cth\u003ECore i3-9100\u003C\/th\u003E\n \u003C\/tr\u003E\n\u003C\/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n  \u003Ctd colspan=\"2\"\u003E# of cores\u003C\/td\u003E\n  \u003Ctd\u003E6\u003C\/td\u003E\n  \u003Ctd\u003E4\u003C\/td\u003E\n \u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd colspan=\"2\"\u003E# of threads\u003C\/td\u003E\n  \u003Ctd\u003E12\u003C\/td\u003E\n  \u003Ctd\u003E4\u003C\/td\u003E\n \u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd colspan=\"4\"\u003EClock speed\u003C\/td\u003E\n \u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd\u003E\u003C\/td\u003E\n  \u003Ctd\u003EBase\u003C\/td\u003E\n  \u003Ctd\u003E3.6GHz\u003C\/td\u003E\n  \u003Ctd\u003E3.6GHz\u003C\/td\u003E\n \u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd\u003E\u003C\/td\u003E\n  \u003Ctd\u003EBoost \/ Turbo\u003C\/td\u003E\n  \u003Ctd\u003E4.2GHz\u003C\/td\u003E\n  \u003Ctd\u003E4.2GHz\u003C\/td\u003E\n \u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd colspan=\"2\"\u003EIntegrated graphics\u003C\/td\u003E\n  \u003Ctd\u003Eno\u003C\/td\u003E\n  \u003Ctd\u003Eyes\u003C\/td\u003E\n \u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd colspan=\"2\"\u003EMemory\u003C\/td\u003E\n  \u003Ctd\u003EDDR4-2933MHz\u003C\/td\u003E\n  \u003Ctd\u003EDDR4-2400MHz\u003C\/td\u003E\n \u003C\/tr\u003E\n\u003C\/tbody\u003E\n\u003C\/table\u003E\n\u003Cbr \/\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nSo, I'm gonna go with the\u0026nbsp;\u003Ca href=\"https:\/\/www.amd.com\/en\/products\/cpu\/amd-ryzen-5-2600x\" style=\"text-align: justify;\"\u003ERyzen5 2600X\u003C\/a\u003E\u0026nbsp;and get a refurbished basic graphics card. \u003Ca href=\"https:\/\/www.blogger.com\/null\" name=\"ancChip\"\u003E\u003C\/a\u003ENext, lets look at the chipset I should go with for the\u0026nbsp;\u003Ca href=\"https:\/\/www.amd.com\/en\/products\/cpu\/amd-ryzen-5-2600x\" style=\"text-align: justify;\"\u003ERyzen5 2600X\u003C\/a\u003E. The processor needs an AM4 chipset. Looking at the \u003Ca href=\"https:\/\/www.amd.com\/en\/products\/chipsets-am4\"\u003EAM4 chipsets\u003C\/a\u003E, there are 3 main categories of chipsets.\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nEssential (A320), Mainstream (B350, B450) and Enthusiast (X370, X470, X570). Lets have a closer look at these.\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u003Ctable align=\"center\" class=\"compare_table\"\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n  \u003Cth style=\"min-width: 200px;\"\u003E\u003C\/th\u003E\n  \u003Cth\u003EA320\u003C\/th\u003E\n  \u003Cth\u003EB350\u003C\/th\u003E\n  \u003Cth\u003EB450\u003C\/th\u003E\n  \u003Cth\u003EX370\u003C\/th\u003E\n  \u003Cth\u003EX470\u003C\/th\u003E\n  \u003Cth\u003EX570\u003C\/th\u003E\n \u003C\/tr\u003E\n\u003C\/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n  \u003Ctd\u003E\u003Cstrong\u003EOverclocking enabled\u003C\/strong\u003E\u003C\/td\u003E\n  \u003Ctd\u003Eno\u003C\/td\u003E\n  \u003Ctd\u003Eyes\u003C\/td\u003E\n  \u003Ctd\u003Eyes\u003C\/td\u003E\n  \u003Ctd\u003Eyes\u003C\/td\u003E\n  \u003Ctd\u003Eyes\u003C\/td\u003E\n  \u003Ctd\u003Eyes\u003C\/td\u003E\n \u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd\u003E\u003Cstrong\u003EMemory overclocking\u003C\/strong\u003E\u003C\/td\u003E\n  \u003Ctd\u003Eyes\u003C\/td\u003E\n  \u003Ctd\u003Eyes\u003C\/td\u003E\n  \u003Ctd\u003Eyes\u003C\/td\u003E\n  \u003Ctd\u003Eyes\u003C\/td\u003E\n  \u003Ctd\u003Eyes\u003C\/td\u003E\n  \u003Ctd\u003Eyes\u003C\/td\u003E\n \u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd\u003E\u003Cstrong\u003EHigh speed platform lanes\u003C\/strong\u003E\u003C\/td\u003E\n  \u003Ctd\u003E24\u003C\/td\u003E\n  \u003Ctd\u003E28\u003C\/td\u003E\n  \u003Ctd\u003E28\u003C\/td\u003E\n  \u003Ctd\u003E38\u003C\/td\u003E\n  \u003Ctd\u003E38\u003C\/td\u003E\n  \u003Ctd\u003E40\u003C\/td\u003E\n \u003C\/tr\u003E\n\u003C\/tbody\u003E\n\u003C\/table\u003E\n\u003Cbr \/\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nI can get the full potential of my processor using a B350, B450 motherboard. \u003Ca href=\"https:\/\/www.asus.com\/us\/Motherboards\/PRIME-B450M-A-CSM\/\"\u003EASUS Prime B450M\u003C\/a\u003E seems like a good option to me.\u0026nbsp; These 2 would cost me around $204. Which is about $30 less than the Intel option but with this configuration I still need a graphics card. The lowest refurbished ones were around $40 to $60 dollars, which seems reasonable.\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Ca href=\"https:\/\/www.blogger.com\/null\" name=\"ancMem\"\u003E\u003C\/a\u003ENext step is to choose Memory.\u0026nbsp;There are a lot of discussions out there indicating Ryzen processors are much more sensitive to memory speeds. This is because of AMD's Infinity Fabrics dependency on the memory clock rate. What you need to remember is overclocking memory does affect the stability of the memory. When choosing memory there are 2 parameters to look for. The speed and the latency. DDR4 is stable at 2400MHz and the higher the speed the more costly they are. I'm not planning to overclock my memory but I would like to get a module, that at least specifies it can work at the chosen processors memory speed. Also I would go with the lowest latency I can get my hands on without breaking the bank.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nSo I'm looking for memory of specification\u0026nbsp;DDR4-2933 (PC4-23466) with a good latency. The latency is specified as column access strobe latency (CAS) which is the delay between the read command and the data availability. There are modules that specify CAS from 14 onward. I found 14c modules to be way too expensive. I chose 2 DIMMs of 8gb each with a latency of 16c for around $75.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ca href=\"https:\/\/www.blogger.com\/null\" name=\"ancPsu\"\u003E\u003C\/a\u003ELets look at power supply options now. There are a few parameters to look for when choosing the power supply\u003Cbr \/\u003E\n\u003Cul\u003E\n\u003Cli\u003EMain parameters\u003C\/li\u003E\n\u003Cul\u003E\n\u003Cli\u003EMaximum power (400W, 500W etc...)\u003C\/li\u003E\n\u003Cli\u003EEnergy efficiency (80+, 80+ gold etc...)\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cli\u003ESecondary parameters\u003C\/li\u003E\n\u003Cul\u003E\n\u003Cli\u003EModulararity\u003C\/li\u003E\n\u003Cli\u003E12v rails\u003C\/li\u003E\n\u003Cli\u003ESATA power connectors, SLI ready, Main connector pins\u003C\/li\u003E\n\u003Cli\u003EForm factor ( ATX, mATX etc...)\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003C\/ul\u003E\n\u003Cdiv\u003E\nFirst off, I need to calculate the maximum wattage for my system. I don't want to get a power supply that can't produce the power output required by my components for obvious reasons. I also don't want to get a power supply with more wattage either, cos more wattage rated PSUs are more expensive. There are many online calculators that allows you to input your devices and get the required wattage for your system. I used a couple of them and it seems I can safely say my power usage is going to be below 350W. So I'm gonna add a margin of error and go with a PSU of 400W.\u0026nbsp;\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\nThen comes the energy efficiency rating. There is a voluntary PSU rating certification program called \u003Ca href=\"https:\/\/en.wikipedia.org\/wiki\/80_Plus\"\u003E80 Plus\u003C\/a\u003E. The basic concept is how efficiently the power supply converts the AC energy to DC. Why is this important? When converting AC to DC current the lost efficiency is generally dissipated as heat. So the more efficient your power supply is the less heat it generates. Also that means you pay less on your electricity bill. Another point I would assume is, in order to create a more efficient PSU the manufacturers would have to use better rated components. The parameters I looked at in the\u0026nbsp;\u003Ca href=\"https:\/\/en.wikipedia.org\/wiki\/80_Plus\"\u003E80 Plus\u003C\/a\u003E\u0026nbsp;program are,\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Ctable align=\"center\" class=\"compare_table\"\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n  \u003Cth style=\"min-width: 150px;\"\u003ELoad %\u003C\/th\u003E\n  \u003Cth\u003E20%\u003C\/th\u003E\n  \u003Cth\u003E50%\u003C\/th\u003E\n  \u003Cth\u003E100%\u003C\/th\u003E\n \u003C\/tr\u003E\n\u003C\/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n  \u003Ctd\u003E\u003Cstrong\u003E80 Plus\u003C\/strong\u003E\u003C\/td\u003E\n  \u003Ctd\u003E80%\u003C\/td\u003E\n  \u003Ctd\u003E80%\u003C\/td\u003E\n  \u003Ctd\u003E80%\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd\u003E\u003Cstrong\u003E80 Plus Bronze\u003C\/strong\u003E\u003C\/td\u003E\n  \u003Ctd\u003E82%\u003C\/td\u003E\n  \u003Ctd\u003E85%\u003C\/td\u003E\n  \u003Ctd\u003E82%\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd\u003E\u003Cstrong\u003E80 Plus Silver\u003C\/strong\u003E\u003C\/td\u003E\n  \u003Ctd\u003E85%\u003C\/td\u003E\n  \u003Ctd\u003E88%\u003C\/td\u003E\n  \u003Ctd\u003E85%\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd\u003E\u003Cstrong\u003E80 Plus Gold\u003C\/strong\u003E\u003C\/td\u003E\n  \u003Ctd\u003E87%\u003C\/td\u003E\n  \u003Ctd\u003E90%\u003C\/td\u003E\n  \u003Ctd\u003E87%\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd\u003E\u003Cstrong\u003E80 Plus Platinum\u003C\/strong\u003E\u003C\/td\u003E\n  \u003Ctd\u003E90%\u003C\/td\u003E\n  \u003Ctd\u003E92%\u003C\/td\u003E\n  \u003Ctd\u003E89%\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003Ctr\u003E\n  \u003Ctd\u003E\u003Cstrong\u003E80 Plus Titanium\u003C\/strong\u003E\u003C\/td\u003E\n  \u003Ctd\u003E92%\u003C\/td\u003E\n  \u003Ctd\u003E94%\u003C\/td\u003E\n  \u003Ctd\u003E90%\u003C\/td\u003E\n\u003C\/tr\u003E\n\u003C\/tbody\u003E\n\u003C\/table\u003E\n\u003Cbr \/\u003E\n\u003Cdiv\u003E\nThere are power supplies that are modular which means you can remove the unwanted power cables. There are various levels of modular like full, semi etc. This is purely an aesthetic aspect which will help in your builds cable management. Then I have to make sure the PSU can be fixed into the casing I choose, have enough SATA power cables to support the devices I'm going to add etc etc...\u003Cbr \/\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\nConsidering all the above and the price, I chose a \u003Ca href=\"https:\/\/www.evga.com\/products\/product.aspx?pn=100-BR-0450-K1\"\u003E450W Bronze\u003C\/a\u003E power supply.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ca href=\"https:\/\/www.blogger.com\/null\" name=\"ancHdd\"\u003E\u003C\/a\u003EThen comes the hard drive selection. Or should I say storage mediums. The speed of the storage medium would significantly affect application load speeds and OS load speeds. The board I chose supports M.2 SSDs. Which is the fastest option at the moment. When choosing a M.2 SSD it's very important to look closer at the motherboard specification. Whether it supports M.2 SATA or PCIE etc etc. In my motherboard specification it says I can use\u0026nbsp;type 2242\/2260\/2280\/22110 with PCIE 3.0 support. For the time being I'm not going to go with a M.2 purely due to my allocated budget for this project. I have an existing SATA SSD which I'm going to use to install my server OS. Over time I would watch my server performance and may consider upgrading.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nNow all that's remaining is to choose a casing that supports my motherboard (mATX) and start building the server. Stay tuned...\u003C\/div\u003E\n\u003Cbr \/\u003E"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/830349664271273779\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2019\/12\/intel-vs-amd-for-my-server.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/830349664271273779"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/830349664271273779"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2019\/12\/intel-vs-amd-for-my-server.html","title":"Intel vs AMD for my server"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgIJGT5ulr3YbWZHJAXqokoNqjdwoPW4rV_jIpWmLja03I2oehOwAERFb_yTai1Io-RjtPS6LUn8wy6ymBX8pjetAupFN739MXEVX7Vd38uRYxOqgESS6zWwM6bXKgoOFSK-F6DwdIL3v0\/s72-c\/IMG_7188.jpg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-1600754415877889680"},"published":{"$t":"2019-11-26T21:47:00.000-08:00"},"updated":{"$t":"2019-12-01T15:26:29.320-08:00"},"title":{"type":"text","$t":"Building a Server from scratch (Part 1)"},"content":{"type":"html","$t":"\u003Cbr \/\u003E\n\u003Ctable align=\"center\" cellpadding=\"0\" cellspacing=\"0\" class=\"tr-caption-container\" style=\"margin-left: auto; margin-right: auto; text-align: center;\"\u003E\u003Ctbody\u003E\n\u003Ctr\u003E\u003Ctd style=\"text-align: center;\"\u003E\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhlVXncKyDhSyJUfW5Z5G0hRSS9Ubb-GZVykZ7zJ2Jqlzvs3uYi_8fdgH9B5P7XNo2DIpnZS2Z84aShDtLURe1xBGy1PBIr56ZCRQiU770udhSbu39cF-hEg9AoPlfRaWoljKgFsWXztFk\/s1600\/DellMB.jpg\" imageanchor=\"1\" style=\"margin-left: auto; margin-right: auto;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1200\" data-original-width=\"1600\" height=\"480\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhlVXncKyDhSyJUfW5Z5G0hRSS9Ubb-GZVykZ7zJ2Jqlzvs3uYi_8fdgH9B5P7XNo2DIpnZS2Z84aShDtLURe1xBGy1PBIr56ZCRQiU770udhSbu39cF-hEg9AoPlfRaWoljKgFsWXztFk\/s640\/DellMB.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/td\u003E\u003C\/tr\u003E\n\u003Ctr\u003E\u003Ctd class=\"tr-caption\" style=\"text-align: center;\"\u003EMy Dell Laptop MB from 2011\u003C\/td\u003E\u003C\/tr\u003E\n\u003C\/tbody\u003E\u003C\/table\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nThere was a time back in the day where everyone used PCs. Laptops were not as powerful and were rare and expensive. I would say I have build roughly about 20 PCs from scratch for friends and family during those good old times. Nowadays building a PC seems like something only gamers do.\u0026nbsp;\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nI'm in the process of building a home server. I will talk about the whys in a future post. In the process I had to refresh my knowledge about building a PC. In this post I will outline the thought process I went through while building my Server from scratch.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nFirst step is to decide on the processor. Everything else evolves around the processor. For example,\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cul\u003E\n\u003Cli\u003EMotherboard chip-set\u0026nbsp;\u003C\/li\u003E\n\u003Cli\u003EMemory speeds\u003C\/li\u003E\n\u003Cli\u003EMemory types ( M.2, Ultra M.2 )\u003C\/li\u003E\n\u003Cli\u003EI\/O capabilities ( SATA 6Gb\/s, 3Gb\/s )\u003C\/li\u003E\n\u003Cli\u003EOn-board graphics\u003C\/li\u003E\n\u003Cli\u003EOverclocking\u003C\/li\u003E\n\u003Cli\u003EBudget\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cdiv\u003E\nFirst I checked Intel processors. I assumed that Intel is going to be more expensive compared to AMD so the idea is to find the best Intel Processor \/ MB combo for my project and then find a comparable AMD alternative to see the price difference.\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\nOn the time of the writing latest Intel's were the Core 10th Gen. I'm on a budget so I was looking at the older generations to grab a bargain. 7th gen processors weren't seen much in retailers. 8th gen processors were plenty available. Intel released the 300 series chipsets for the 8th and 9th gen Core processors. These chipsets are not backward compatible with the 7th gen processors. That means in order to use a 7th gen processor I need to find a motherboard with a 200 series chipset. Even though it was possible the options were limited. So I started looking at the 8th Gen Intel Core processors.\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\nIntel Core i3 8th gen has a F series. They are very competitively priced. One important fact though is they don't have integrated graphics. So if I'm to go with a F series I need a separate graphics card. I was going for integrated graphics as I don't need much graphics performance. It's going to be a server so most of the time I don't need a GUI. The base level Core i3 8th Gen is the \u003Ca href=\"https:\/\/ark.intel.com\/content\/www\/us\/en\/ark\/products\/126688\/intel-core-i3-8100-processor-6m-cache-3-60-ghz.html\"\u003Ei3-8100\u003C\/a\u003E\u0026nbsp;which was retailing for around $134. The base 9th gen \u003Ca href=\"https:\/\/ark.intel.com\/content\/www\/us\/en\/ark\/products\/134870\/intel-core-i3-9100-processor-6m-cache-up-to-4-20-ghz.html\"\u003Ei3-9100\u003C\/a\u003E was retailing for around $153. The specs looked quite \u003Ca href=\"https:\/\/ark.intel.com\/content\/www\/us\/en\/ark\/compare.html?productIds=134870,126688\"\u003Esimilar\u003C\/a\u003E\u0026nbsp;except for \u003Ca href=\"https:\/\/www.intel.com\/content\/www\/us\/en\/support\/articles\/000030893\/processors.html\"\u003ETurbo Boost\u003C\/a\u003E. I decided if I'm going with Intel,\u0026nbsp;\u003Ca href=\"https:\/\/ark.intel.com\/content\/www\/us\/en\/ark\/products\/134870\/intel-core-i3-9100-processor-6m-cache-up-to-4-20-ghz.html\"\u003Ei3-9100\u003C\/a\u003E\u0026nbsp;is the one I would choose because I would get a newer processor for almost the same price.\u0026nbsp;\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\nThen comes the challenge of choosing the right chipset. It's good practice to figure out the chipset you need before deciding on the motherboard. Motherboard capabilities are determined by the chipset. The processor empowers the chipset and the motherboard to do it's job.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\nFor the Core-i3 processor I need a 300 series chipset. As mentioned before Intel 8th gen onwards Carry's a backward incompatible change. Z370 was the first high end chipset which was replaced by Z390 a year later. The other options in superior to inferior order are Q370, H370, B360, H310. Lets look at the features I used to decide on the chipset for my build.\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003C\/div\u003E\n\u003Cstyle\u003E\n        .compare_table table {\n            border-collapse: collapse;\n            table-layout: fixed;\n            width: 900px;\n        }\n        .compare_table th,\n        .compare_table td {\n            padding: 15px;\n            border: 1px solid green;\n            width: 100px;\n        }\n    \u003C\/style\u003E\n    \u003Cbr \/\u003E\n\u003Ctable class=\"compare_table\"\u003E\n        \u003Cthead\u003E\n\u003Ctr\u003E\n                \u003Cth\u003E\u003C\/th\u003E\n                \u003Cth\u003EZ390\u003C\/th\u003E\n                \u003Cth\u003EQ370\u003C\/th\u003E\n                \u003Cth\u003EH370\u003C\/th\u003E\n                \u003Cth\u003EB360\u003C\/th\u003E\n                \u003Cth\u003EH310\u003C\/th\u003E\n            \u003C\/tr\u003E\n\u003C\/thead\u003E\n        \u003Ctbody\u003E\n\u003Ctr\u003E\n                \u003Ctd\u003E\u003Cstrong\u003EBus Speed\u003C\/strong\u003E\u003C\/td\u003E\n                \u003Ctd\u003E8 GT\/s\u003C\/td\u003E\n                \u003Ctd\u003E8 GT\/s\u003C\/td\u003E\n                \u003Ctd\u003E8 GT\/s\u003C\/td\u003E\n                \u003Ctd\u003E8 GT\/s\u003C\/td\u003E\n                \u003Ctd\u003E5 GT\/s\u003C\/td\u003E\n            \u003C\/tr\u003E\n\u003Ctr\u003E\n                \u003Ctd\u003E\u003Cstrong\u003E# of DIMMs per channel\u003C\/strong\u003E\u003C\/td\u003E\n                \u003Ctd\u003E2\u003C\/td\u003E\n                \u003Ctd\u003E2\u003C\/td\u003E\n                \u003Ctd\u003E2\u003C\/td\u003E\n                \u003Ctd\u003E2\u003C\/td\u003E\n                \u003Ctd\u003E1\u003C\/td\u003E\n            \u003C\/tr\u003E\n\u003Ctr\u003E\n                \u003Ctd\u003E\u003Cstrong\u003E# of displays supported\u003C\/strong\u003E\u003C\/td\u003E\n                \u003Ctd\u003E3\u003C\/td\u003E\n                \u003Ctd\u003E3\u003C\/td\u003E\n                \u003Ctd\u003E3\u003C\/td\u003E\n                \u003Ctd\u003E3\u003C\/td\u003E\n                \u003Ctd\u003E2\u003C\/td\u003E\n            \u003C\/tr\u003E\n\u003Ctr\u003E\n                \u003Ctd\u003E\u003Cstrong\u003EPCI Express revision\u003C\/strong\u003E\u003C\/td\u003E\n                \u003Ctd\u003E3.0\u003C\/td\u003E\n                \u003Ctd\u003E3.0\u003C\/td\u003E\n                \u003Ctd\u003E3.0\u003C\/td\u003E\n                \u003Ctd\u003E3.0\u003C\/td\u003E\n                \u003Ctd\u003E2.0\u003C\/td\u003E\n            \u003C\/tr\u003E\n\u003Ctr\u003E\n                \u003Ctd\u003E\u003Cstrong\u003EMax PCI Express lanes\u003C\/strong\u003E\u003C\/td\u003E\n                \u003Ctd\u003E24\u003C\/td\u003E\n                \u003Ctd\u003E24\u003C\/td\u003E\n                \u003Ctd\u003E20\u003C\/td\u003E\n                \u003Ctd\u003E12\u003C\/td\u003E\n                \u003Ctd\u003E6\u003C\/td\u003E\n            \u003C\/tr\u003E\n\u003Ctr\u003E\n                \u003Ctd\u003E\u003Cstrong\u003E# of USB ports\u003C\/strong\u003E\u003C\/td\u003E\n                \u003Ctd\u003E14\u003C\/td\u003E\n                \u003Ctd\u003E14\u003C\/td\u003E\n                \u003Ctd\u003E14\u003C\/td\u003E\n                \u003Ctd\u003E12\u003C\/td\u003E\n                \u003Ctd\u003E10\u003C\/td\u003E\n            \u003C\/tr\u003E\n\u003Ctr\u003E\n                \u003Ctd\u003E\u003Cstrong\u003EMax # SATA 6.0 Gb\/s ports\u003C\/strong\u003E\u003C\/td\u003E\n                \u003Ctd\u003E6\u003C\/td\u003E\n                \u003Ctd\u003E6\u003C\/td\u003E\n                \u003Ctd\u003E6\u003C\/td\u003E\n                \u003Ctd\u003E6\u003C\/td\u003E\n                \u003Ctd\u003E4\u003C\/td\u003E\n            \u003C\/tr\u003E\n\u003Ctr\u003E\n                \u003Ctd\u003E\u003Cstrong\u003EPCI Express configurations\u003C\/strong\u003E\u003C\/td\u003E\n                \u003Ctd\u003E1x16 or 2x8 or 1x8+2x4\u003C\/td\u003E\n                \u003Ctd\u003E1x16 or 2x8 or 1x8+2x4\u003C\/td\u003E\n                \u003Ctd\u003E1x16\u003C\/td\u003E\n                \u003Ctd\u003E1x16\u003C\/td\u003E\n                \u003Ctd\u003E1x16\u003C\/td\u003E\n            \u003C\/tr\u003E\n\u003C\/tbody\u003E\n    \u003C\/table\u003E\n\u003Cbr \/\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nLooking at the i3-9100 \u003Ca href=\"https:\/\/ark.intel.com\/content\/www\/us\/en\/ark\/products\/134870\/intel-core-i3-9100-processor-6m-cache-up-to-4-20-ghz.html\"\u003Especifications\u003C\/a\u003E\u0026nbsp;the bus speed of the processor is 8 GT\/s. So the H310 is out as it only supports bus speeds upto 5 GT\/s. B360 only has 12 PCI express lanes for HSIO (high speed input output) and the i3-9100 has 16 PCIe lanes. This all depends on how the motherboard assigns the lanes. These numbers also determine how many components can connect at the same time. I'm not planning to add a graphics card which has the highest demand for the PCI Express lanes. Chipsets with less lanes has lesser options for components like the number of PCI Express solts, M.2 slots, SATA ports, USB ports etc. The only component I would probably add is a multi port PCIe eathernet card for virtualization. So I can go with a B360 motherboard. Also something to note the B360 chipset doesn't support processor or memory overclocking even with unlocked processors. Only K series processors support overclocking so I can't benefit from overclocking feature anyways. When buying memory paying extra for faster DIMMs than 2400MHz ones would be a waste.\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nIf I go with a motherboard like ASUS \u003Ca href=\"https:\/\/www.asus.com\/us\/Motherboards\/PRIME-B360M-A\/\"\u003EB360M-A\u003C\/a\u003E\u0026nbsp;I can acquire the motherboard and the processor for around $235. Now is the time to look up a comparable AMD MB and Processor combo. Stay tuned...\u0026nbsp;\u0026nbsp;\u003C\/div\u003E\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/1600754415877889680\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2019\/11\/building-server-from-scratch.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/1600754415877889680"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/1600754415877889680"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2019\/11\/building-server-from-scratch.html","title":"Building a Server from scratch (Part 1)"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhlVXncKyDhSyJUfW5Z5G0hRSS9Ubb-GZVykZ7zJ2Jqlzvs3uYi_8fdgH9B5P7XNo2DIpnZS2Z84aShDtLURe1xBGy1PBIr56ZCRQiU770udhSbu39cF-hEg9AoPlfRaWoljKgFsWXztFk\/s72-c\/DellMB.jpg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-8827827890806376951"},"published":{"$t":"2019-11-17T21:43:00.001-08:00"},"updated":{"$t":"2019-11-17T21:43:46.445-08:00"},"title":{"type":"text","$t":"NAS Build (Part III)"},"content":{"type":"html","$t":"(This is a continuation of my previous \u003Ca href=\"https:\/\/blog.coding-mayhem.com\/2019\/11\/nas-build-part-ii.html\"\u003Epost\u003C\/a\u003E...)\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhc4pfc8pXBSQRJ6zRsN-j-CNl1KVRK-tMmqVpOR7fOVsBDNDJKSnzUL_pE35YuAjZtf285EuJsT4GYrvgrzdp0QHJliwmMOVjxOPgAATJowthG4E9aoTbwP2INBF0JWXrIqvErMcmnY0M\/s1600\/1.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"339\" data-original-width=\"1600\" height=\"132\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhc4pfc8pXBSQRJ6zRsN-j-CNl1KVRK-tMmqVpOR7fOVsBDNDJKSnzUL_pE35YuAjZtf285EuJsT4GYrvgrzdp0QHJliwmMOVjxOPgAATJowthG4E9aoTbwP2INBF0JWXrIqvErMcmnY0M\/s640\/1.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEh-UNMxi2eksCTFCWVPTYvwq7y2KCs2hUALlU30oUAOpRPtfL-LugZi_YyYwXk3XbnJJdY3UdcfpyAxvItayyUDgJbwEm4rMyuIBnvMHMmc9uRC02zNCeyVQj0jEmGB_sCZrZ_UtBBdXlU\/s1600\/2.jpg\" imageanchor=\"1\" style=\"clear: right; float: right; margin-bottom: 1em; margin-left: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1600\" data-original-width=\"1084\" height=\"400\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEh-UNMxi2eksCTFCWVPTYvwq7y2KCs2hUALlU30oUAOpRPtfL-LugZi_YyYwXk3XbnJJdY3UdcfpyAxvItayyUDgJbwEm4rMyuIBnvMHMmc9uRC02zNCeyVQj0jEmGB_sCZrZ_UtBBdXlU\/s400\/2.jpg\" width=\"270\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nIn the last post I talked about the RAID failure and needing to find a new solution. In this post I will talk about the solution I went with.\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nIf we are to look back at the failed attempt, the reason I decided to go with a RAID5 setup with my 3 disks is\u0026nbsp;\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003C\/div\u003E\n\u003Cul\u003E\n\u003Cli\u003EHaving a 2TB disk\u003C\/li\u003E\n\u003Cul\u003E\n\u003Cli\u003EBetween vindy and myself having 2TBs seem enough space\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cli\u003ERedundancy\u0026nbsp;\u003C\/li\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe Raid5 disk can survive a 1 disk failure due to the way parity works.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003C\/ul\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nHow do I achieve similar outcomes without the power of RAID. The answer is \"Nightly Backups\"\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nFirst of all I nuked the RAID5 and partitioned the disks again. This time I created 3 NTFS partitions. Did I hear you ask why NTFS. If for some reason I get the disks out of the NAS I have the flexibility to plug them into any of the devices we use. As mentioned in my first NAS post I use an XPS 15 with Ubuntu and Vindi uses an XPS 12 with Windows. I mounted these so that OMV knows about them. This was done in `Storage` --\u0026gt; `File Systems` option.\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgbj1R3gf1gcPZ26P2sYGmMuxHqRN02cgayQ2RgtpoisV3mt3tfVxBQmYlJOOrVRK6rwFQdRL3Toxy3wAVIxOXmJvHJGgFQajayq54j9c4rOHkVD0TMCWbz_NrSuAsSNbeZ6r9HRKUufa8\/s1600\/Sharesjpg.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"937\" data-original-width=\"1600\" height=\"372\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgbj1R3gf1gcPZ26P2sYGmMuxHqRN02cgayQ2RgtpoisV3mt3tfVxBQmYlJOOrVRK6rwFQdRL3Toxy3wAVIxOXmJvHJGgFQajayq54j9c4rOHkVD0TMCWbz_NrSuAsSNbeZ6r9HRKUufa8\/s640\/Sharesjpg.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E\nThen I created 3 shared folders in the 3 HDDs. You can easily do this in `AccessRightsManagement` --\u0026gt; `Shared Folders` section. I'm gonn use one of those shares as my data drive, another as Vindi's data drive and the third as Vindi's backup drive. What about my backup drive you would ask. Well I was able to get redundancy for 2TBs using RAID5 and 3 * 1 TB HDDs but with the new approach this is a compromise I had to make. Theoretically it doesn't make sense to create a shared folder for a backup drive but you will see why soon.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhYAKGHRD2810hSIXgae9j7_qbGuCaqeC7MKqU6lLJfiT5eR0B9AulwaoTv6M1HJgRFjFPGfW-JcOiQbSrFzV-y0j9oA8eqVwH8Vl21rbalGyGiAafC0R9Hnbs4-K-SfaKq8mCF-8h91sE\/s1600\/SMB.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1042\" data-original-width=\"1600\" height=\"416\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhYAKGHRD2810hSIXgae9j7_qbGuCaqeC7MKqU6lLJfiT5eR0B9AulwaoTv6M1HJgRFjFPGfW-JcOiQbSrFzV-y0j9oA8eqVwH8Vl21rbalGyGiAafC0R9Hnbs4-K-SfaKq8mCF-8h91sE\/s640\/SMB.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nNext step is to create the shares. I'm using SMB shares for this. The reason is I have Windows and Linux machines in my network. I created them using the `Services` -\u0026gt; `SMB` -\u0026gt; `Shares` option. The UI is intuitive and straight forward. As you can see in the above screenshot I have not exposed the backup folder via SMB.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjV-kyInN90w2bnQYYw-fQMdfDexCudzmMRnhcdYQmKah9ueR3fJNOvbxCv6IzAUKHxF1UFiazW8YzCnnzcJvdF8_b8SJVf2YiSoPNx8A2cO4hywFx-uYCCalkqkZWL4b6HgQCythtt85Q\/s1600\/RSyncjpg.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1283\" data-original-width=\"1600\" height=\"512\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjV-kyInN90w2bnQYYw-fQMdfDexCudzmMRnhcdYQmKah9ueR3fJNOvbxCv6IzAUKHxF1UFiazW8YzCnnzcJvdF8_b8SJVf2YiSoPNx8A2cO4hywFx-uYCCalkqkZWL4b6HgQCythtt85Q\/s640\/RSyncjpg.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E\nNext is the daily backup job. OMV comes with the ability to create rsync cron jobs out of the box. This can be achieved via `Services` -\u0026gt; `Rsync` -\u0026gt; `Jobs`.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEg0IUwmGSduC5fS8ysiIJT4TFbR8Lh-Pc3Hu3cKOeuDmZJjpdGB3whyNNtDjhChg_wwIn-055tubP1lHeO9kx4mr3rm37pFLiyv7NsHCtqifsGLTI4FLAFoOS9ze54hlHq_ZlfhkuYcMk0\/s1600\/rsyncdetails.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"721\" data-original-width=\"1600\" height=\"288\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEg0IUwmGSduC5fS8ysiIJT4TFbR8Lh-Pc3Hu3cKOeuDmZJjpdGB3whyNNtDjhChg_wwIn-055tubP1lHeO9kx4mr3rm37pFLiyv7NsHCtqifsGLTI4FLAFoOS9ze54hlHq_ZlfhkuYcMk0\/s640\/rsyncdetails.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E\nYou can create a pretty decent rsync chron job using the UI. What more can you ask for. Well there you go. That was it.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nI have had this running for a good 3 weeks without any issues. I'm happy with the setup. Below is the load average of the system. The spikes you see every day are due to the cron job.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEj7KJttzWCCl9UOqvxtid_iOcUioqNTMiOQ7HXhBBFf9GBfeO906T0HFsyN7jHPp-ulrE2zeyqOruKbjD10KoYJJwHtpLHQN0MBhDnx1A8boEJ3Pw00Cd9JpCGrxlzeFPmMiK3Ygzjupf0\/s1600\/cron.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"592\" data-original-width=\"1210\" height=\"312\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEj7KJttzWCCl9UOqvxtid_iOcUioqNTMiOQ7HXhBBFf9GBfeO906T0HFsyN7jHPp-ulrE2zeyqOruKbjD10KoYJJwHtpLHQN0MBhDnx1A8boEJ3Pw00Cd9JpCGrxlzeFPmMiK3Ygzjupf0\/s640\/cron.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/8827827890806376951\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2019\/11\/nas-build-part-iii.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/8827827890806376951"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/8827827890806376951"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2019\/11\/nas-build-part-iii.html","title":"NAS Build (Part III)"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhc4pfc8pXBSQRJ6zRsN-j-CNl1KVRK-tMmqVpOR7fOVsBDNDJKSnzUL_pE35YuAjZtf285EuJsT4GYrvgrzdp0QHJliwmMOVjxOPgAATJowthG4E9aoTbwP2INBF0JWXrIqvErMcmnY0M\/s72-c\/1.jpg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-3490617347705768577"},"published":{"$t":"2019-11-10T12:23:00.000-08:00"},"updated":{"$t":"2019-11-11T08:24:50.958-08:00"},"category":[{"scheme":"http://www.blogger.com/atom/ns#","term":"NAS"},{"scheme":"http://www.blogger.com/atom/ns#","term":"OMV"},{"scheme":"http://www.blogger.com/atom/ns#","term":"RAID"},{"scheme":"http://www.blogger.com/atom/ns#","term":"Raspberry PI 4"}],"title":{"type":"text","$t":"NAS Build (Part II)"},"content":{"type":"html","$t":"\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEi-S8eUFLW4o0_r6dqF15gGW3JfCSYxNHe3z3VgTZXl-INSqFyIwXF0KH-PHaMoeJejgMLFtUxc9okL9wttyHysLYIcG9bcM5xq_Z_UqJ_jZ2_WLxnDWDue_ddijZtHCISgp91jVcVQVgM\/s1600\/Nas2.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1196\" data-original-width=\"1600\" height=\"475\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEi-S8eUFLW4o0_r6dqF15gGW3JfCSYxNHe3z3VgTZXl-INSqFyIwXF0KH-PHaMoeJejgMLFtUxc9okL9wttyHysLYIcG9bcM5xq_Z_UqJ_jZ2_WLxnDWDue_ddijZtHCISgp91jVcVQVgM\/s640\/Nas2.jpg\" width=\"640\" \/\u003E\u003C\/span\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E(This is a continuation from my \u003Ca href=\"https:\/\/blog.coding-mayhem.com\/2019\/11\/nas-build-with-raspberry-pi-4.html\"\u003Eprevious post\u003C\/a\u003E ...)\u003C\/span\u003E\u003Cbr \/\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003EIn this post I'll be covering the following,\u003C\/span\u003E\u003Cbr \/\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cspan style=\"font-family: inherit;\"\u003ESoftware setup\u0026nbsp;\u003C\/span\u003E\u003C\/li\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cspan style=\"font-family: inherit;\"\u003EOpen Media Vault\u0026nbsp;\u003C\/span\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Cspan style=\"font-family: inherit;\"\u003ESoftware Raid using `mdadm`\u003C\/span\u003E\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cli\u003E\u003Cspan style=\"font-family: inherit;\"\u003EExpectation vs Reality\u003C\/span\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Cspan style=\"font-family: inherit;\"\u003ERaid setup failure\u003C\/span\u003E\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cdiv\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003EI received my hardware. Happy days ...\u003C\/span\u003E\u003Cbr \/\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjA-JfwQzH4xsqFLreAVZG09DmV7FbIQIx2UtGi_cgjxB8ClqGzTwF_OcNjFLvdHESryIBZPdD1rmuoeRgyHPKTqFer2OhMm-KqZITpqslCZ5_pnuvg9i2FaraGiWrClZ2qvL1CLTV2cqs\/s1600\/hardware2.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1200\" data-original-width=\"1600\" height=\"480\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjA-JfwQzH4xsqFLreAVZG09DmV7FbIQIx2UtGi_cgjxB8ClqGzTwF_OcNjFLvdHESryIBZPdD1rmuoeRgyHPKTqFer2OhMm-KqZITpqslCZ5_pnuvg9i2FaraGiWrClZ2qvL1CLTV2cqs\/s640\/hardware2.jpg\" width=\"640\" \/\u003E\u003C\/span\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: justify;\"\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003EBefore narrowing down the hardware I was looking at \u003Ca href=\"https:\/\/www.openmediavault.org\/\"\u003EOMV\u003C\/a\u003E (OpenMediaVault) and \u003Ca href=\"https:\/\/www.freenas.org\/\"\u003EFreeNAS\u003C\/a\u003E. Newer versions of FreeNAS needs a 64 bit processor and at a minimum 8 gigs of memory. No need to say I went with OMV.\u0026nbsp;\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Ch2\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\nSoftware Setup\u003C\/span\u003E\u003C\/h2\u003E\n\u003Ch3\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\nOpen Media Vault\u003C\/span\u003E\u003C\/h3\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003EOMV has an images for single board computers (SBC). This made installing OMV in the Pi a peace of cake. There are so many good and even more bad videos on how to do this. I recommend \u003Ca href=\"https:\/\/www.youtube.com\/watch?v=vY5PSAhjD2s\"\u003Ethis\u003C\/a\u003E by TDL.\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003EThere are a few gotchas to watch out for. First of all get the correct image for your board. Second of all patience is a virtue. OMV images for SBCs go through a setup in the first boot. If the first boot is unable to complete the setup your image is toast. Make sure you have wired network available to your board and also did I mention patience is a virtue. My initial setup took a little over 10 minutes but word on the street is it can take up to 20 minutes. If you have a monitor connected to the board you will see the control panel address when the setup is complete.\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEidDRmVxt6dzCnKAgkbztlp7br23rPmVrcoybERTSql914tlohfzKBu9dxiNMI4tC_CViMIgjGoVbVgRlMe49YyMmazMZqbT-Ti0kjOibxk4hgSTst6EowB6Lij9V743E-kTO0mFz0wTwo\/s1600\/setupdone.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cimg border=\"0\" data-original-height=\"908\" data-original-width=\"1600\" height=\"361\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEidDRmVxt6dzCnKAgkbztlp7br23rPmVrcoybERTSql914tlohfzKBu9dxiNMI4tC_CViMIgjGoVbVgRlMe49YyMmazMZqbT-Ti0kjOibxk4hgSTst6EowB6Lij9V743E-kTO0mFz0wTwo\/s640\/setupdone.jpg\" width=\"640\" \/\u003E\u003C\/span\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: left;\"\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003EAfter the setup I did the obvious things like changing the password, setting up ssh, enabling SMB, upgrading all the packages etc. The video I linked does a good job of explaining how to do these.\u0026nbsp;\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\n\u003Ch3\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\nSoftware Raid\u003C\/span\u003E\u003C\/h3\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003EOMV has a UI for creating RAID systems but it doesn't allow creating RAIDs out of USB drives (with good reason)\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEho-4WE020gQnr3tM9FN_ysQkht_ELCk8-mM2tliWpst_lycktWnGHlGHyuzzKSUwYexq8DGxrpWvk8wCWpVJ4FUYsWtiOnD3J75ogwP610onqrKYKVOCw9HbCl3GuBPSOp_BJwyQyVd_o\/s1600\/OMV1.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cimg border=\"0\" data-original-height=\"960\" data-original-width=\"1352\" height=\"454\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEho-4WE020gQnr3tM9FN_ysQkht_ELCk8-mM2tliWpst_lycktWnGHlGHyuzzKSUwYexq8DGxrpWvk8wCWpVJ4FUYsWtiOnD3J75ogwP610onqrKYKVOCw9HbCl3GuBPSOp_BJwyQyVd_o\/s640\/OMV1.jpg\" width=\"640\" \/\u003E\u003C\/span\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003ESo I opted to use \u003Ca href=\"https:\/\/linux.die.net\/man\/8\/mdadm\"\u003Emdadm\u003C\/a\u003E to create a software raid using my USB drives. I elected to go with a \u003Ca href=\"https:\/\/en.wikipedia.org\/wiki\/Standard_RAID_levels\"\u003ERAID5\u003C\/a\u003E setup. As mentioned earlier I have 3 USB HDDs of 1TB size. I can create a 2TB raid disk using RAID5 and be good even if one drive fails. Raid 5 uses a distributed parity system where it stores parity data randomly across all 3 drives. That means if one drive fails the data in that drive can be recovered using the parity information stored in the other two drives. Plus I get to have a 2TB disk 😊. So I ordered another 2TB disk which I'm gonna use as a manual geo redundant backup 😁.\u0026nbsp;\u003C\/span\u003E\u003Cbr \/\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003EHere are the steps I used to create the RAID5\u003C\/span\u003E\u003Cbr \/\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003EFirst you need to find your disk information. The following command will help you get that\u003C\/span\u003E\u003Cbr \/\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cb\u003E\u003Cspan style=\"font-family: \u0026quot;courier new\u0026quot; , \u0026quot;courier\u0026quot; , monospace;\"\u003Elsblk -o NAME,SIZE,FSTYPE,TYPE,MOUNTPOINT\u003C\/span\u003E\u003C\/b\u003E\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cdiv style=\"text-align: left;\"\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003EThen you need to create the raid. The disk information goes into this command as an input\u003C\/span\u003E\u003C\/div\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cspan style=\"font-family: \u0026quot;courier new\u0026quot; , \u0026quot;courier\u0026quot; , monospace;\"\u003E\u003Cb\u003Esudo mdadm --create --verbose \/dev\/md0 --level=5 --raid-devices=3 \/dev\/sda \/dev\/sdb \/dev\/sdc \u003C\/b\u003E\u003C\/span\u003E\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cdiv style=\"text-align: left;\"\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003EThis is going to take some time. For me it took about 6 hours. the progress of this operation can be viewed by looking at the `\/proc\/mdstat` file. You need to print the content of the file in order to view the progress. It's overwritten always so a command like `tail` will not work.\u003C\/span\u003E\u003C\/div\u003E\n\u003C\/div\u003E\n\u003Cdiv style=\"text-align: left;\"\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cspan style=\"font-family: \u0026quot;courier new\u0026quot; , \u0026quot;courier\u0026quot; , monospace;\"\u003E\u003Cb\u003Ecat \/proc\/mdstat\u003C\/b\u003E\u003C\/span\u003E\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003C\/div\u003E\n\u003C\/div\u003E\n\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cspan style=\"font-family: inherit;\"\u003EOnce the raid is created you will see something like this on your `mdstat` file.\u003C\/span\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhUdxCC-35WmlZRevp6fdr5b6Z0JWrgQ0wempIlBFxoavjt8HV7c2jtYsP9OvxfPC8dObdP1ECaUk5ZS5jbvUgEtypP4Cxf1xpqRqGsDYziiv__CS23ll8gfBChfbyjnZ-vCF7n7FGqbbg\/s1600\/raid_created.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"305\" data-original-width=\"1600\" height=\"122\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhUdxCC-35WmlZRevp6fdr5b6Z0JWrgQ0wempIlBFxoavjt8HV7c2jtYsP9OvxfPC8dObdP1ECaUk5ZS5jbvUgEtypP4Cxf1xpqRqGsDYziiv__CS23ll8gfBChfbyjnZ-vCF7n7FGqbbg\/s640\/raid_created.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nNow is the time to tell linux about your RAID array. You do that by adding the raid information to the `mdadm.conf` file. In the below command I'm using `mdadm` to scan the active array and pipe the output to the `mdadm.conf` file using `tee` command\u003C\/div\u003E\n\u003Cdiv style=\"text-align: left;\"\u003E\n\u003C\/div\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cspan style=\"font-family: \u0026quot;courier new\u0026quot; , \u0026quot;courier\u0026quot; , monospace;\"\u003E\u003Cb\u003Esudo mdadm --detail --scan | sudo tee -a \/etc\/mdadm\/mdadm.conf\u003C\/b\u003E\u003C\/span\u003E\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cdiv\u003E\nNext we update the initial RAM file system so that the array is available during boot\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cb\u003E\u003Cspan style=\"font-family: \u0026quot;courier new\u0026quot; , \u0026quot;courier\u0026quot; , monospace;\"\u003Esudo update-initramfs -u\u003C\/span\u003E\u003C\/b\u003E\u0026nbsp;\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nNow if you go back to the OMV RAID management page you should see the newly created RAID array. Next step is to create the partitions and file systems in the raid array. You can do this in the `File Systems` tab in OMV. The whole process is intuitive so I won't go into details.\u003C\/div\u003E\n\u003C\/div\u003E\n\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgUGsY6JcIrSu7zE_dlsTGVTVMm0CtMIYIUH6Nd2ufKAKU0gxow5iFNfjjUFlSAnvi-9uRJgqJX7rA_MZEqfnvS0pm0KJkJCUQJM0Cnl36RhKNm6N5cZZn1Cv1GhrDC1RQHI8giQ_nh3jY\/s1600\/OMVFileSystem.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1126\" data-original-width=\"1414\" height=\"508\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgUGsY6JcIrSu7zE_dlsTGVTVMm0CtMIYIUH6Nd2ufKAKU0gxow5iFNfjjUFlSAnvi-9uRJgqJX7rA_MZEqfnvS0pm0KJkJCUQJM0Cnl36RhKNm6N5cZZn1Cv1GhrDC1RQHI8giQ_nh3jY\/s640\/OMVFileSystem.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nNow check your `\/etc\/fstab` file. I found out that OMV added my raid file system information here using labels. I changed it to use name instead. It just comes down to your preference.\u003C\/div\u003E\n\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\nThere are a lot of info about RAID in the wild. I found \u003Ca href=\"https:\/\/www.digitalocean.com\/community\/tutorials\/how-to-create-raid-arrays-with-mdadm-on-ubuntu-16-04\"\u003Ethis\u003C\/a\u003E article to be the most helpful.\u003C\/div\u003E\n\u003Ch2\u003E\nExpectation vs Reality\u003C\/h2\u003E\n\u003Cdiv\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nAt this point I had both the hardware and software set up. I went ahead and created a few SMB shares with custom permissions and went straight into testing the system. I chose the SMB option because I have both Linux and Windows machines in my network. If you need more help in setting up SMB I would recommend \u003Ca href=\"https:\/\/www.youtube.com\/watch?v=QVm92VqCaGA\"\u003Ethis\u003C\/a\u003E video form TDL.\u003C\/div\u003E\n\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhYfPNXxsXKL22As6GJxKrrLBa-1Nz109XqtbtkmQHLAJO6Vfn5a0zTDAWLaBx_KFXy4_DWZ4cIyRLCQQsejgKd0ybJUMz-sDmmYVcp48lmby9TOBP6u3ul70bAo6woeDh_nU8bxtvAedo\/s1600\/NAS.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"551\" data-original-width=\"641\" height=\"550\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhYfPNXxsXKL22As6GJxKrrLBa-1Nz109XqtbtkmQHLAJO6Vfn5a0zTDAWLaBx_KFXy4_DWZ4cIyRLCQQsejgKd0ybJUMz-sDmmYVcp48lmby9TOBP6u3ul70bAo6woeDh_nU8bxtvAedo\/s640\/NAS.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nThe first thing I did was to issue a reboot command. The Pi hanged at boot. After some reading I found out that the Pi 4 has a problem with powered USB hubs. It fails to boot when the hub back powers the Pi. This is a \u003Ca href=\"https:\/\/www.raspberrypi.org\/forums\/viewtopic.php?t=245218\"\u003Eknown\u003C\/a\u003E issue. People have had mixed experiences by disconnecting the back powering from the USB hub. I chose not to go down this path due to a few reasons.\u0026nbsp;\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nIn order to monkey around with the hubs circuit board I need my electronics kit, which I left back at home in Melbourne when I moved to Seattle 🙉. I'm not planning to reboot this setup often so I chose to momentarily unplug the hubs every time I reboot.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nThen comes the biggest shock. Before setting up all this I checked the speeds. I was getting between 8\/12Mbps using a simple SMB setup between the PI and my laptop. So I expected to get the same speeds with the RAID5. Theoretically a RAID5 should give higher write speeds. In my case I knew the HDD speed was not the bottleneck for 12Mbps\u0026nbsp; I was getting. The HDDs I used is capable of operating at around 32Mbps. So I wasn't expecting the 12Mbps to go up with the RAID5 but I was not ready for the result I saw.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nI was only getting about 0.5Mbps. I could not come up with a definitive explanation but it seems the Pi shares the same controller and bus between the USB and Ethernet. I confirmed this by transferring data between my raid and a USB stick directly plugged into the Pi. I was feeling defeated at this point. This is just the tip of the iceberg.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nSo after realizing my mistake I just let the system run for a few days. Truth be told I was looking for a good way to break the news to Vindy (my wife). She is a tecky herself and a witty one. So I knew I'm gonna hear about this 🥴. Did I say this was just the tip of the iceberg.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nI was checking on the system from time to time and I realized the RAID array kept degrading randomly.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgEfn96ZkT8AW0VYUX36AtE2TTfDWZjq6XR95iHepByWGJYQQ-6FVRyLZz8U8-eRoc2Gr3AiGvKcg2p66A8zIjLxYLcf9YQLvrCI1hOpOqH1CV4_AcphpWwzUZRL5CMUXlv6ZPMjQ4koD0\/s1600\/RaidDegraded.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"339\" data-original-width=\"1600\" height=\"134\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgEfn96ZkT8AW0VYUX36AtE2TTfDWZjq6XR95iHepByWGJYQQ-6FVRyLZz8U8-eRoc2Gr3AiGvKcg2p66A8zIjLxYLcf9YQLvrCI1hOpOqH1CV4_AcphpWwzUZRL5CMUXlv6ZPMjQ4koD0\/s640\/RaidDegraded.jpg\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E\nOne of the 3 HDDs were getting detached frequently. I tried experimenting with various spin down settings to no avail. The first few times I regenerated the RAID by reattaching the missing disk.\u003Cbr \/\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cb\u003E\u003Cspan style=\"font-family: \u0026quot;courier new\u0026quot; , \u0026quot;courier\u0026quot; , monospace;\"\u003Esudo mdadm \/dev\/md0 -a \/dev\/sdd\u003C\/span\u003E\u003C\/b\u003E\u0026nbsp;\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cdiv\u003E\nAfter a few attempts I knew that this is not going to work. Stay tuned to find out more about my solution...\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u0026nbsp;\u0026nbsp; \u0026nbsp;\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/3490617347705768577\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2019\/11\/nas-build-part-ii.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/3490617347705768577"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/3490617347705768577"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2019\/11\/nas-build-part-ii.html","title":"NAS Build (Part II)"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEi-S8eUFLW4o0_r6dqF15gGW3JfCSYxNHe3z3VgTZXl-INSqFyIwXF0KH-PHaMoeJejgMLFtUxc9okL9wttyHysLYIcG9bcM5xq_Z_UqJ_jZ2_WLxnDWDue_ddijZtHCISgp91jVcVQVgM\/s72-c\/Nas2.jpg","height":"72","width":"72"},"thr$total":{"$t":"0"},"georss$featurename":{"$t":"Seattle, WA, USA"},"georss$point":{"$t":"47.6062095 -122.3320708"},"georss$box":{"$t":"47.2636695 -122.9775178 47.9487495 -121.68662379999999"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-4981177093277121795"},"published":{"$t":"2019-11-04T18:24:00.000-08:00"},"updated":{"$t":"2019-11-05T21:40:28.618-08:00"},"title":{"type":"text","$t":"NAS build"},"content":{"type":"html","$t":"\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgeS1JZ2_RLd_9pFg1fYbeBzg3d6y7yKWw83oiSsxixZeXLM5NAkrEg58P4-8qygeM6QwTHVBkTT5-hkXCcLVQ2Fp73mSkRhI9i_s4FeEG_vZN4Mm-tuffY3vkf8k2pofSfO0N4LXF-aeo\/s1600\/IMG_20191019_070706.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"1600\" data-original-width=\"1200\" height=\"320\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgeS1JZ2_RLd_9pFg1fYbeBzg3d6y7yKWw83oiSsxixZeXLM5NAkrEg58P4-8qygeM6QwTHVBkTT5-hkXCcLVQ2Fp73mSkRhI9i_s4FeEG_vZN4Mm-tuffY3vkf8k2pofSfO0N4LXF-aeo\/s320\/IMG_20191019_070706.jpg\" width=\"240\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nSo this is the story about my NAS (\u003Ca href=\"https:\/\/en.wikipedia.org\/wiki\/Network-attached_storage\"\u003ENetwork attached storage\u003C\/a\u003E) build. Spoiler alert, the initial implementation fails but I manage to compromise and go for a plan B.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ch2\u003E\nWhy ?\u003C\/h2\u003E\nWell why not. :). Actually to tell you a bit of the back story. I use a Dell XPS 15 running \u003Ca href=\"https:\/\/ubuntu.com\/\"\u003EUbuntu\u003C\/a\u003E as my PC and my better half uses a Dell XPS 12 running \u003Ca href=\"https:\/\/www.microsoft.com\/en-us\/windows\/get-windows-10\"\u003EWindows 10\u003C\/a\u003E. Our on-board disk sizes are 500GB and 250GB respectively.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nWe keep our data in external USB drives. Vindi ( my wife ) does food photography as a side gig \/ hobby. So I think it's fair to say that we were having a bit of an inconvenience when it comes to storage.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ch2\u003E\nHow ?\u003C\/h2\u003E\nSo now to find a solution to my problem. The decision to go with a NAS was quite obvious\u003Cbr \/\u003E\n\u003Cul\u003E\n\u003Cli\u003EAccessible to both of us\u003C\/li\u003E\n\u003Cli\u003EAccessible by all our devices\u003C\/li\u003E\n\u003Cli\u003ELess upkeep after initial setup\u003C\/li\u003E\n\u003Cli\u003ESeems like a fun project 😉\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Ch3\u003E\nHardware\u003C\/h3\u003E\n\u003Cdiv\u003E\nI know there are off the shelf systems but where's the fun in that.\u0026nbsp;\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\nI wanted to keep the cost to a minimum so I decided to use the existing USB hard disks I have. I had 3 USB HDDs. 1TB each.\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\nNext I needed a lightweight machine. After a bit of research I was going back and forth between 2 options.\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cul\u003E\n\u003Cli\u003EAn embedded system like\u003C\/li\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u0026nbsp;\u003Ca href=\"https:\/\/www.asrock.com\/mb\/Intel\/Q1900M\/\"\u003EASRock Q1900M\u003C\/a\u003E\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003C\/ul\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEicYBXFHSF8-Bed2RFdcwG4vtxUvmSL-u46M4LLDhcxapUBk82pJ72AATB94igh6_w1LRZIRSJuvZRnBOwqjnszMTlfMq2AHB-XCBocOiPBJpv3YsbsvOBXlh79hXoNCx-nKTkoL1wDjIQ\/s1600\/Q1900M.jpeg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"205\" data-original-width=\"246\" height=\"166\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEicYBXFHSF8-Bed2RFdcwG4vtxUvmSL-u46M4LLDhcxapUBk82pJ72AATB94igh6_w1LRZIRSJuvZRnBOwqjnszMTlfMq2AHB-XCBocOiPBJpv3YsbsvOBXlh79hXoNCx-nKTkoL1wDjIQ\/s200\/Q1900M.jpeg\" width=\"200\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: left;\"\u003E\nIt was quite tempting to go for an embedded system. The system comes with Processor, VGA, and Mother Board and all I had to source was the memory modules and a casing with a power supply unit. I could have sourced all that for under $200. Unlike the single board computers I can make use of the SATA interface in the embedded system. ( Or can I ? )\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: left;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: left;\"\u003E\nThere are a few reasons why I didn't go down this path. First of all my aim was to open up the USB drives I had and get the SATA drives hidden within them. Luckily for me just before I start butchering the USB HDDs I found out that they don't have SATA HDDs inside of them. At least 2 out of the 3 for \u003Ca href=\"https:\/\/www.youtube.com\/watch?v=DBenRlPzb_s\"\u003Esure didn't\u003C\/a\u003E. So I won't be able to use the SATA interface in the embedded system.\u0026nbsp;\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: left;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: left;\"\u003E\nSecond of all I have never worked on a single board computer like a RaspberryPI. I have fiddled around with \u003Ca href=\"https:\/\/en.wikipedia.org\/wiki\/PIC_microcontrollers\"\u003EPIC\u003C\/a\u003E microchips back in the days but these single board computers seem way ahead of the game compared to those. So I decided to go down that path.\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: left;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cul\u003E\n\u003Cli\u003EA single board computer\u003C\/li\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\"https:\/\/www.raspberrypi.org\/products\/raspberry-pi-4-model-b\/\"\u003ERaspberry Pi 4\u003C\/a\u003E\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003C\/ul\u003E\n\u003Cdiv\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjLq4fFWsumw7jcOeefGPzeU21k6YfDJDE6Ya6qRGIXIAD3x5IvF7KxzyPzuMfgU2I3IVLKKyv2J0sTfmMcBPBXB7nsyr70kttye1AtOgfFsnlurVkS27OC_PGsHI56KUenJuQQ_OeZWkE\/s1600\/pi4.jpeg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"172\" data-original-width=\"294\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjLq4fFWsumw7jcOeefGPzeU21k6YfDJDE6Ya6qRGIXIAD3x5IvF7KxzyPzuMfgU2I3IVLKKyv2J0sTfmMcBPBXB7nsyr70kttye1AtOgfFsnlurVkS27OC_PGsHI56KUenJuQQ_OeZWkE\/s1600\/pi4.jpeg\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cul\u003E\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\"https:\/\/www.pine64.org\/devices\/single-board-computers\/rock64\/\"\u003ERock64\u003C\/a\u003E\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003C\/ul\u003E\n\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjk6Oe5APl3K_Eks7QNxJ5JluHC3jOEeyplJyoEWsy9FOMTbvHpCzKCNyzag35iRXo-hUIIy4Dq52CKP2LSJDh1leC5B9VovrJdo3P7YuLJ_43y409K0GdpgLwucyi9XF77L8wNswfydRo\/s1600\/ROCK64_sideimg.jpg\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" data-original-height=\"354\" data-original-width=\"305\" height=\"200\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEjk6Oe5APl3K_Eks7QNxJ5JluHC3jOEeyplJyoEWsy9FOMTbvHpCzKCNyzag35iRXo-hUIIy4Dq52CKP2LSJDh1leC5B9VovrJdo3P7YuLJ_43y409K0GdpgLwucyi9XF77L8wNswfydRo\/s200\/ROCK64_sideimg.jpg\" width=\"171\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\nSo I narrowed down my options to either be the Raspberry PI 4 or the Rock64. I went with the PI due to the 2 USB 3.0 ports and due to its popularity.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nNow it's time to decide on the software. Stay tuned...\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nPS: I did some tests to determine the expected speeds before the build. I'd write about the expectation and reality in a later post. 😁\u003C\/div\u003E\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/4981177093277121795\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2019\/11\/nas-build-with-raspberry-pi-4.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/4981177093277121795"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/4981177093277121795"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2019\/11\/nas-build-with-raspberry-pi-4.html","title":"NAS build"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEgeS1JZ2_RLd_9pFg1fYbeBzg3d6y7yKWw83oiSsxixZeXLM5NAkrEg58P4-8qygeM6QwTHVBkTT5-hkXCcLVQ2Fp73mSkRhI9i_s4FeEG_vZN4Mm-tuffY3vkf8k2pofSfO0N4LXF-aeo\/s72-c\/IMG_20191019_070706.jpg","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-7957601785919557219"},"published":{"$t":"2016-07-31T04:59:00.002-07:00"},"updated":{"$t":"2016-08-01T03:10:52.044-07:00"},"title":{"type":"text","$t":"Handling Time Zone across from UI to DB"},"content":{"type":"html","$t":"So I thought about writing a bit about handling date variables across WebAPI to Angular.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ch3\u003E\nThe Problem\u003C\/h3\u003E\n\u003Cbr \/\u003E\nWhen saving date \/ datetime values sent from the front end how do we keep the timezone information and how do we show them back on the UI.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nFor example let's say you use a simple online note taking application. You wrote a note 8 am in the morning when you were in WA. Now you are looking at your notes from Vic and what time do you want the note to show as the created date\/time ?\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nWell I can't tell you what time you should show as it's a business case but I can tell you how to store the time information so that regardless of what time you need to show, you can achieve that.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ch3\u003E\nThe Solution\u003C\/h3\u003E\n\u003Cdiv\u003E\nCode on GitHub\u0026nbsp;\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Ca href=\"https:\/\/www.blogger.com\/goog_1787025619\"\u003E\u003Cbr \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Ca href=\"https:\/\/github.com\/kumudug\/DateTime-handling-Front-to-Back\"\u003Ehttps:\/\/github.com\/kumudug\/DateTime-handling-Front-to-Back\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\nWhat the code demos......\u003Cbr \/\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Ch4\u003E\nOn the WebAPI side\u003C\/h4\u003E\nYou can store the date time values in 2 ways\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cul\u003E\n\u003Cli\u003EAs UTC values in a DateTime variable in SQL Server\u003C\/li\u003E\n\u003Cli\u003EIn a DateTimeOffset variable in SQL server with the actual date time. The advantage of this is that you can actually know which time zone the input originated from.\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cbr \/\u003E\n\u003Ch4\u003E\nOn the Front End\u003C\/h4\u003E\nI have demoed sending of the data from the Angular front end to the WebAPI layer using the following 2 libraries in the code.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\"https:\/\/angular-ui.github.io\/bootstrap\/\"\u003EAngular UI Bootstrap\u003C\/a\u003E\u003C\/li\u003E\n\u003Cli\u003E\u003Ca href=\"http:\/\/mgcrea.github.io\/angular-strap\/\"\u003EAngular Strap\u003C\/a\u003E\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/7957601785919557219\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2016\/07\/handling-time-zone-across-from-ui-to-db_60.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/7957601785919557219"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/7957601785919557219"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2016\/07\/handling-time-zone-across-from-ui-to-db_60.html","title":"Handling Time Zone across from UI to DB"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-879995323207599918"},"published":{"$t":"2016-06-10T16:37:00.000-07:00"},"updated":{"$t":"2016-09-18T04:43:13.343-07:00"},"title":{"type":"text","$t":"Setting up KDiff3 to work with TortoiseGIT"},"content":{"type":"html","$t":"\u003Ch2\u003E\nSetting up KDiff3 to work with TortoiseGIT\u003C\/h2\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nWhen I work with git I do most of my work on the command line except for a few tasks. Resolving conflicts is one of them. So when it comes to resolving conflicts I prefer the KDiff3 instead of the TortoiseGIT diff. I was googling around to find the proper way and after grabbing things from difference places here is what I did.\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\nObvious steps \u0026nbsp;:)\u003C\/div\u003E\n\u003Cdiv style=\"text-align: justify;\"\u003E\n\u003Col\u003E\n\u003Cli\u003EInstall git\u003C\/li\u003E\n\u003Cli\u003EInstall KDiff3\u003C\/li\u003E\n\u003Cli\u003EInstall TortoiseGIT\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003C\/div\u003E\n\u003Cdiv\u003E\nThen after that\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Col\u003E\n\u003Cli\u003EOpen tortoise GIT settings\u003C\/li\u003E\n\u003Col\u003E\n\u003Cli\u003EGo to \"Diff Viewer\"\u003C\/li\u003E\n\u003Cli\u003EGo to section \"Configure the program used for comparing different revisio....\"\u003C\/li\u003E\n\u003Cli\u003EChoose \"External\"\u003C\/li\u003E\n\u003Cli\u003EEnter the following \"[ kdiff3 instalation path ]\\kdiff3.exe %base %mine --L1 %bname --L2 %yname\"\u003C\/li\u003E\n\u003Cli\u003EGo to \"Merge Tool\"\u003C\/li\u003E\n\u003Cli\u003EChoose \"External\"\u003C\/li\u003E\n\u003Cli\u003EEnter the following \"[ kdiff3 instalation path ]\\kdiff3.exe %base %mine %theirs -o %merged\"\u003C\/li\u003E\n\u003Cli\u003EClick apply\u003C\/li\u003E\n\u003C\/ol\u003E\n\u003C\/ol\u003E\n\u003Cdiv\u003E\nI will soon write something to show how to actually resolve conflicts using this setup.\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhUAzyQD-VDw58PXlHCGR36pxeVb4SlRF0Zq8txYoQquEWZ0Akz55x10Dxg_I-u5sSlpKKgwfUbmAXjeuEzqVnvgP5NyBLnTMV8236L__01piXqa6p2gioEmxJilQrGbQLfNtkb4rEL90A\/s1600\/GitDiff.png\" imageanchor=\"1\" style=\"margin-left: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" height=\"504\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhUAzyQD-VDw58PXlHCGR36pxeVb4SlRF0Zq8txYoQquEWZ0Akz55x10Dxg_I-u5sSlpKKgwfUbmAXjeuEzqVnvgP5NyBLnTMV8236L__01piXqa6p2gioEmxJilQrGbQLfNtkb4rEL90A\/s640\/GitDiff.png\" width=\"640\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/879995323207599918\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2016\/06\/setting-up-kdiff3-to-work-with.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/879995323207599918"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/879995323207599918"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2016\/06\/setting-up-kdiff3-to-work-with.html","title":"Setting up KDiff3 to work with TortoiseGIT"}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhUAzyQD-VDw58PXlHCGR36pxeVb4SlRF0Zq8txYoQquEWZ0Akz55x10Dxg_I-u5sSlpKKgwfUbmAXjeuEzqVnvgP5NyBLnTMV8236L__01piXqa6p2gioEmxJilQrGbQLfNtkb4rEL90A\/s72-c\/GitDiff.png","height":"72","width":"72"},"thr$total":{"$t":"0"}},{"id":{"$t":"tag:blogger.com,1999:blog-9001854664565320016.post-6731558766730002491"},"published":{"$t":"2016-04-19T05:50:00.000-07:00"},"updated":{"$t":"2016-04-19T05:58:24.551-07:00"},"title":{"type":"text","$t":"Angular directive to validate a decimal input field with min\/max "},"content":{"type":"html","$t":"I came across a requirement to validate a simple numeric input field with a minimum and a maximum value. So I tried using the html5 validations first like so\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cspan style=\"background-color: white; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003E\u0026nbsp;\u0026nbsp;\u003C\/span\u003E\u003Cspan class=\"highLT\" style=\"box-sizing: border-box; color: blue; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003E\u0026lt;\u003C\/span\u003E\u003Cspan class=\"highELE\" style=\"box-sizing: border-box; color: brown; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003Einput\u003C\/span\u003E\u003Cspan style=\"background-color: white; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003E\u0026nbsp;\u003C\/span\u003E\u003Cspan class=\"highATT\" style=\"box-sizing: border-box; color: red; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003Etype=\u003C\/span\u003E\u003Cspan class=\"highVAL\" style=\"box-sizing: border-box; color: mediumblue; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003E\"number\"\u003C\/span\u003E\u003Cspan style=\"background-color: white; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003E\u0026nbsp;\u003C\/span\u003E\u003Cspan class=\"highATT\" style=\"box-sizing: border-box; color: red; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003Ename=\u003C\/span\u003E\u003Cspan class=\"highVAL\" style=\"box-sizing: border-box; color: mediumblue; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003E\"quantity\"\u003C\/span\u003E\u003Cspan style=\"background-color: white; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003E\u0026nbsp;\u003C\/span\u003E\u003Cspan class=\"highATT\" style=\"box-sizing: border-box; color: red; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003Emin=\u003C\/span\u003E\u003Cspan class=\"highVAL\" style=\"box-sizing: border-box; color: mediumblue; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003E\"1\"\u003C\/span\u003E\u003Cspan style=\"background-color: white; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003E\u0026nbsp;\u003C\/span\u003E\u003Cspan class=\"highATT\" style=\"box-sizing: border-box; color: red; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003Emax=\u003C\/span\u003E\u003Cspan class=\"highVAL\" style=\"box-sizing: border-box; color: mediumblue; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003E\"5\"\u003C\/span\u003E\u003Cspan class=\"highGT\" style=\"box-sizing: border-box; color: blue; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003E\u0026gt;\u003C\/span\u003E\u003Cbr \/\u003E\n\u003Cspan class=\"highGT\" style=\"box-sizing: border-box; color: blue; font-family: \u0026quot;consolas\u0026quot; , \u0026quot;courier new\u0026quot;; font-size: 16px; line-height: 22.4px;\"\u003E\u003Cbr \/\u003E\u003C\/span\u003E\n\nThe above works perfectly until you need to have decimal minimum and maximum values. So I thought why not write a directive to support this.\u003Cbr \/\u003E\n\u003Cbr \/\u003E\nThe code can be found in the following GitHub repo\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Ca href=\"https:\/\/github.com\/kumudug\/angular_min_max_validate\"\u003Ehttps:\/\/github.com\/kumudug\/angular_min_max_validate\u003C\/a\u003E\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u0026nbsp;The directive supports the following\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003Cul\u003E\n\u003Cli\u003EDecimal min and max validation\u003C\/li\u003E\n\u003Cli\u003EDecimal min value only validation\u003C\/li\u003E\n\u003Cli\u003EDecimal max value only validation\u003C\/li\u003E\n\u003C\/ul\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\n\u003Cu\u003EUsage\u003C\/u\u003E\u003Cbr \/\u003E\n\u003Cbr \/\u003E\n\u003C!-- HTML generated using hilite.me --\u003E\u003Cbr \/\u003E\n\u003Cdiv style=\"background: #ffffff; border-width: 0.1em 0.1em 0.1em 0.8em; border: solid gray; overflow: auto; padding: 0.2em 0.6em; width: auto;\"\u003E\n\u003Cpre style=\"line-height: 125%; margin: 0;\"\u003E\u003Cspan style=\"color: #007700;\"\u003E\u0026lt;input\u003C\/span\u003E \u003Cspan style=\"color: #0000cc;\"\u003Etype=\u003C\/span\u003E\u003Cspan style=\"background-color: #fff0f0;\"\u003E\"number\"\u003C\/span\u003E \u003Cspan style=\"color: #0000cc;\"\u003Eclass=\u003C\/span\u003E\u003Cspan style=\"background-color: #fff0f0;\"\u003E\"form-control\"\u003C\/span\u003E \u003Cspan style=\"color: #0000cc;\"\u003Eid=\u003C\/span\u003E\u003Cspan style=\"background-color: #fff0f0;\"\u003E\"decimal1\"\u003C\/span\u003E \u003Cspan style=\"color: #0000cc;\"\u003Ename=\u003C\/span\u003E\u003Cspan style=\"background-color: #fff0f0;\"\u003E\"decimal1\"\u003C\/span\u003E \u003Cspan style=\"color: #0000cc;\"\u003Eng-model=\u003C\/span\u003E\u003Cspan style=\"background-color: #fff0f0;\"\u003E\"vm.dec1\"\u003C\/span\u003E \u003Cspan style=\"color: #0000cc;\"\u003Erequired\u003C\/span\u003E \u003Cspan style=\"color: #0000cc;\"\u003Ech-validate-min-max\u003C\/span\u003E \u003Cspan style=\"color: #0000cc;\"\u003Emin-val=\u003C\/span\u003E\u003Cspan style=\"background-color: #fff0f0;\"\u003E\"0.2\"\u003C\/span\u003E \u003Cspan style=\"color: #0000cc;\"\u003Emax-val=\u003C\/span\u003E\u003Cspan style=\"background-color: #fff0f0;\"\u003E\"1.7\"\u003C\/span\u003E\u003Cspan style=\"color: #007700;\"\u003E\u0026gt;\u003C\/span\u003E\n\u003C\/pre\u003E\n\u003C\/div\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cu\u003EInitial View\u003C\/u\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEipK0MlqRKBkW0uDION7H52QtXjCM8lj3FBTgJe2m0jMiGxr4vNIDNlU_g86zYavDjYXlGxIB4XByOhj9YhH3_ft8jgWG6edR-CusTDm8NgpHrdRZrE7_yEKVa1gUBLScMZcg6HTySqyno\/s1600\/2016-04-19+22_45_00-Angular+min-max+validate+-+Internet+Explorer.png\" imageanchor=\"1\" style=\"clear: left; float: left; margin-bottom: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEipK0MlqRKBkW0uDION7H52QtXjCM8lj3FBTgJe2m0jMiGxr4vNIDNlU_g86zYavDjYXlGxIB4XByOhj9YhH3_ft8jgWG6edR-CusTDm8NgpHrdRZrE7_yEKVa1gUBLScMZcg6HTySqyno\/s1600\/2016-04-19+22_45_00-Angular+min-max+validate+-+Internet+Explorer.png\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cbr \/\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cu\u003EAfter validation is triggered\u003C\/u\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv class=\"separator\" style=\"clear: both; text-align: center;\"\u003E\n\u003Ca href=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhyJndKyHDx82U_LZ_Hujqb6hRhBrpaSNcK3JoKLxolsT4muujly3LJ4nr3tgwCGuYXHlMRFpYZEZeqPXr9JRZB_ekIJPamASo6xtuNqDPH-qsGbQbYofQi0d-R5Sduid7ThI5J_ljc9Js\/s1600\/2016-04-19+22_46_54-Angular+min-max+validate+-+Internet+Explorer.png\" imageanchor=\"1\" style=\"clear: left; float: left; margin-bottom: 1em; margin-right: 1em;\"\u003E\u003Cimg border=\"0\" src=\"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEhyJndKyHDx82U_LZ_Hujqb6hRhBrpaSNcK3JoKLxolsT4muujly3LJ4nr3tgwCGuYXHlMRFpYZEZeqPXr9JRZB_ekIJPamASo6xtuNqDPH-qsGbQbYofQi0d-R5Sduid7ThI5J_ljc9Js\/s1600\/2016-04-19+22_46_54-Angular+min-max+validate+-+Internet+Explorer.png\" \/\u003E\u003C\/a\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n\u003Cdiv\u003E\n\u003Cbr \/\u003E\u003C\/div\u003E\n"},"link":[{"rel":"replies","type":"application/atom+xml","href":"https:\/\/blog.coding-mayhem.com\/feeds\/6731558766730002491\/comments\/default","title":"Post Comments"},{"rel":"replies","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2016\/04\/angular-directive-to-validate-decimal.html#comment-form","title":"0 Comments"},{"rel":"edit","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/6731558766730002491"},{"rel":"self","type":"application/atom+xml","href":"https:\/\/www.blogger.com\/feeds\/9001854664565320016\/posts\/default\/6731558766730002491"},{"rel":"alternate","type":"text/html","href":"https:\/\/blog.coding-mayhem.com\/2016\/04\/angular-directive-to-validate-decimal.html","title":"Angular directive to validate a decimal input field with min\/max "}],"author":[{"name":{"$t":"Kumudu"},"uri":{"$t":"http:\/\/www.blogger.com\/profile\/09631376530178263243"},"email":{"$t":"noreply@blogger.com"},"gd$image":{"rel":"http://schemas.google.com/g/2005#thumbnail","width":"32","height":"26","src":"\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEheIXkdSfQ4sSU9aHt6G6U_FEwkJ_a_jBR5viyuYuJZQYWNRx5uqmA8oTm0lD_Z2F0E2DECZ0glaex2H0eKqIVuszBkQ4ha6M9DKpSh8rPWy6qJ7OQINJ8zFOvrMEqoJA\/s113\/IMG_0077_1.jpg"}}],"media$thumbnail":{"xmlns$media":"http://search.yahoo.com/mrss/","url":"https:\/\/blogger.googleusercontent.com\/img\/b\/R29vZ2xl\/AVvXsEipK0MlqRKBkW0uDION7H52QtXjCM8lj3FBTgJe2m0jMiGxr4vNIDNlU_g86zYavDjYXlGxIB4XByOhj9YhH3_ft8jgWG6edR-CusTDm8NgpHrdRZrE7_yEKVa1gUBLScMZcg6HTySqyno\/s72-c\/2016-04-19+22_45_00-Angular+min-max+validate+-+Internet+Explorer.png","height":"72","width":"72"},"thr$total":{"$t":"0"}}]}}