[{"content":"\nI have been maintaining a git monorepo named docker_templates since sometime in 2023, and I recently added my 200th template (commit 7c112b7)! I thought back to 2015, when I started learning Docker and Docker Compose, and how transformative containers have been in the way I use my machines. There is something very satisfying about describing a runtime you want to work with and encapsulating everything you need to run an app or service the same way each time you run it, on any machine. In reality, there are some edge cases, but it\u0026rsquo;s a beautiful dream.\nThe structure of the docker_templates repository continues to evolve, but it\u0026rsquo;s also come a long way from where it started. I use this repository in some way almost every day. It is the heart of my homelab, and is where I keep references for pretty much every service I\u0026rsquo;ve stood up on one of my machines. I will now indulge my nostalgia by showing you how the repository has grown over time, how I use it, and share a few of my most useful/favorite containers.\nThe Beginning: Disparate Directories with Docker Compose Files I learned to write Dockerfiles to containerize my Python programs, and quickly picked up Docker Compose so I could run things like a Postgres database or Redis message queue (for Python\u0026rsquo;s Celery scheduling library). Eventually, I started to find programs and apps I wanted to host myself, like a media server, document hosting, monitoring/alerting services, etc.\nFor years, whenever I wanted to try a new service, I would create a directory for it, write a docker-compose.yml file, and run it. I was hardcoding non-secret values in my Compose files for a long time (using a .env or environment variables to pass secrets to the template). Sometimes I would initialize a git repository and push the stack to Github, other times the directory would just sit on one of my machines wherever I originally put it. The mental overhead of deciding if the new service I wanted to try was worthy of initializing as a git repository, making sure my .gitignore would keep my .env file out of git history and ignore host volume mounts, and deciding where to put the code on my machine started to slow me down and made me hesitant to really invest time into the templates I was creating. I ended up with a lot of different repositories and realized this was not sustainable long-term.\nThe Monorepo At some point in 2023, I decided to start a new git monorepo to store all of the services I ran in Docker Compose. I created my docker_templates repository, and one-by-one started copying Compose files I had written into the repository. Pulling everything into one place allowed me to do some cleanup, and made it much easier to start new templates. Whenever I wanted to run a new service, I would clone the repository into a path in my ~/git/ directory, name it after the service, and create the docker-compose.yml file alongside all my other templates in a branch named after the service. Each service existed in an immediate child of the templates/ directory, and over time that path became difficult to sort through.\nThis worked great for a while, but as I added more and more containers, and thought about all of the templates I planned to add in the future, I realized the structure of the templates/ directory would need to change, and the multiplicative git cloning would end up being a storage problem in the long term. I decided to overhaul the organization of templates in the repository, and write scripts to help me manage the complexity and initialize new templates in a standardized way.\nCookiecutter Templates When I would start new service templates, I would usually go to a previous template and copy the docker-compose.yml and README.md files into the new path and rewrite them for the new service. I realized I wanted to standardize the way I initialized new template directories. I was familiar with Jinja2 templating from writing Python programs, and had used Cookiecutter in the past to create templated Python project repositories. So I created a standardized Docker Compose service template directory, with a common starting point for all future templates, and a Python script to guide the user through initializing a new template. I am definitely yada-yada-ing a lot of steps I took to get to this point, but after a few iterations, I settled on the template I have been using for the majority of the time this repository has existed.\nEach new repository created from the Cookiecutter template starts with the same files:\n.docker-compose.template: An empty marker file for some of the maintenance scripts\u0026hellip;I\u0026rsquo;ll explain more later. .env.example: I parameterize my compose.yml files, and provide an example .env file with the defaults for each service. The user is instructed to copy .env.example to .env (which is ignored in the .gitignore for the whole repository) to configure the running stack. .gitignore: Template-local ignore pattern overrides. README.md: The new_template.py script prompts the user for a title, summary, and optional description, and populates the README file with the user\u0026rsquo;s inputs. compose.yml: The Python script generates a Docker Compose template file with the basic shape defined, so I can just start writing service definitions. Path Markers Once the template business was all sorted out, I decided to create subdirectories under the templates/ path that would serve as \u0026ldquo;categories.\u0026rdquo; I put my Postgres, Mariadb, Redis, and InnoDB containers under the database/ path, my Plex and Jellyfin servers under media/, Ntfy and Gotify containers under notifications/, and so on. Splitting things up this way made it easier to browse through the containers on the web, and helped to kept the stacks logically sorted by archetype.\nIn each \u0026ldquo;category\u0026rdquo; directory, I created an empty .category file marker. I used the .category and .docker-compose.template files to write scripts that managed some of the complexity in the repository. For example, the count_templates.py script finds all of the .docker-compose.template files in the repository and returns a count, which I use to update the templates count in the repository\u0026rsquo;s README.md. I wrote a Github workflow to run the script on a schedule, and if the count is different from what\u0026rsquo;s in the README.md, it updates the Templates:\\s*\\d+ pattern with the new count.\nRepository Map I also created a repository \u0026ldquo;map\u0026rdquo;, a README.md file that finds all of the .category markers and creates a file tree from a README.md Jinja template, and a script to update the map README when new categories are created. The update-repo-map.yml Github workflow also runs nightly to keep this file updated with the latest templates.\nRepository Metadata The scripts that update my README files also write data to the metadata/ directory. This can act as a sort of read-only API using cURL requests. For example, the repository map uses the categories.json file to populate the tree, and all .category and .docker-compose.template markers are in beacons.json. I started calling the marker files \u0026ldquo;beacons\u0026rdquo; at one point, and it just kind of stuck.\nThe metadata files are only really used for rendering README templates, but I have plans for things like a frontend webUI to explore the repository\u0026rsquo;s templates, and a Go CLI for downloading and using individual templates.\nGit Sparse Checkouts I mentioned earlier that I was cloning the whole repository each time I wanted to run a template I had in docker_templates. This quickly became a problem as the size of the repository grew. The repository is currently 10MB in size (mostly due to image files and some larger files in history that I\u0026rsquo;ll clean up at some point), so each time I cloned the repository, I added 10MB of disk usage.\nI discovered git sparse checkouts when I searched for a solution to this problem. A sparse checkout is a git operation that allows you to checkout only a subset of the files in a repository. When I\u0026rsquo;m running a Docker Compose template, I don\u0026rsquo;t need to pull the src/img directory, with the .png I render in the main README.md; I can pull just the files in the Compose template I wish to run.\nIn practice, most of my sparse checkouts are ~5% of the total size of the repository, which is about the same amount of size they would take as separate git repositories. It adds a few steps to the initial checkout process, but it makes the clone a focused copy with only as much as I need to run.\nAs an example, if I want to run a Zabbix server container, I would run the following commands:\n1 2 3 4 5 6 7 8 $\u0026gt; git clone --no-checkout https://github.com/redjax/docker_templates docker_zabbix $\u0026gt; cd docker_zabbix ## Initialize sparse checkout $\u0026gt; git sparse-checkout init --cone ## Tell git which paths to checkout $\u0026gt; git sparse-checkout set templates/monitoring_alerting/docker_zabbix ## Checkout the main branch, or a working branch for the zabbix server $\u0026gt; git checkout feat/some-zabbix-feature This would create a directory named docker_zabbix/, which would have all of the files in the root path, and a single directory named templates/, with monitoring_alerting/docker_zabbix. All of the other containers still exist in the remote, but sparse checkouts let me focus on a single template.\nGit Worktrees were not a thing when I started using sparse checkouts. An alternative to the sparse checkout method described above is cloning the whole docker_templates repository once, then creating worktrees for all of the services you want to run. A worktree exists in a separate path on the machine like a sparse clone would. They share the same git object database, meaning paths and objects are deduplicated, which saves space on the disk.\nTo create a Zabbix server container using a worktree, you would clone the whole repository once, cd into it, then create worktrees for each service you want to run. The limitation here is that each worktree checks out a branch, and only that worktree can use that branch. So, the original repository clone stays on the main branch, and each worktree must have its own branch, even if I\u0026rsquo;m not making any changes on that service.\n1 2 3 4 $\u0026gt; git clone https://github.com/redjax/docker_templates $\u0026gt; cd docker_templates $\u0026gt; git worktree add -b feat/zabbix-server ../docker_zabbix $\u0026gt; cd ../docker_zabbix I have continued to use sparse checkouts because I often checkout a service and just run it on the main branch until I have updates or fixes to apply, and this flow does not work with worktrees.\nHow I Use It I have a full clone of this repository on a few of my machines, which I don\u0026rsquo;t modify after cloning except to create new templates. When I want to try a new service, I cd into the local clone, create a branch for the new service, and run the new_template.py script, which prompts me for a template name, category, summary, and optional description. I usually add a link of some sort in the summary, i.e. a Github repository or documentation URL.\nI commit the files as the initial starting point for that repository and push the branch up to git. Then I do a sparse clone and checkout the new service\u0026rsquo;s branch, and I get to work building the compose.yml and .env.example. I usually create host volume mounts in the git path, although a better practice would be storing Docker files in another path, like /opt/docker_data or something. The things we wished we learned sooner\u0026hellip;\nEventually, when the template is in working condition, I merge the branch into main. As I make changes to the template, I create feat/ and fix/ branches, which also get merged into main. The idea/goal is to be running my services off the stable main branch as often as possible.\nFavorite Containers With some containers, I get them into working order, then bring the stack down and forget about it. I still like having the reference file in my repository, but I might not actively run the container. If it\u0026rsquo;s in the main branch, it means I got it running at one point and it\u0026rsquo;s ready to run again.\nBut there are some templates I am constantly running, or re-using on different machines.\ndocker_pangolin: One of the most important services in my stack! Pangolin is my reverse HTTP proxy. I have Pangolin running on a VPS I rent. I route my domain name through Cloudflare, and have Cloudflare pointed at the VPS. In Pangolin, I create subdomain routes to services I want to expose to the Internet, and route the traffic over a private tunnel. docker_adguard: I run an AdGuard Home container for ad blocking on my entire LAN. My router uses the AdGuard container as its DNS server. I create records for my important machines, so I can resolve machine-name.home anywhere on my network instead of remembering IP addresses. docker_netbird: I use Netbird to run a private network, similar to how many people use Tailscale. I use access policies to allow or restrict traffic between devices, and to give my friends limited access to things like my media and game servers. docker_paperless-ngx: Paperless-ngx is an incredibly useful document management system. I upload my receipts, work documents for things like benefits packages and employee handbooks, PDFs, and airline tickets. Daily backups to local and offsite storage prevent data loss (and I\u0026rsquo;ve had to recover a few times; don\u0026rsquo;t neglect your backups!). Full-text search lets me find things quickly, and the tagging system lets me sort documents, with tags like receipt, pets, house, vehicle, etc. docker_concourse_ci: I use Concourse CI for some of my homelab automation pipelines. docker_semaphore: Semaphore lets me run my Ansible roles and playbooks, as well as scheduled Bash scripts and Terraform from a convenient webUI. docker_gickup: Gickup is a useful utility for backing up and mirroring git repositories. I keep a config file with all of my most important/valuable repositories, and Gickup runs on a Raspberry Pi, pulling my changes to a local drive and mirroring some to a self-hosted Git instance. database: A collection of database templates. I frequently use Postgres and Redis in my homelab, and I use InfluxDB for some of my time-series data like weather readings. docker_linkwarden: Linkwarden is a bookmarking/read-it-later app. I imported all of my browser bookmarks when I set it up initially, and I frequently save new links I come across on the web. I can export Linkwarden\u0026rsquo;s saved links as a bookmarks.html file to synchronize back to my browser. docker_forgejo: Forgejo is a self-hosted Git forge, reminiscent of 2015-2018 Github. The Forgejo Actions platform is semi-compatible with Github actions, and Forgejo\u0026rsquo;s pipelines are very similar in syntax. I host a private/local-only Git forge for some of my more sensitive repositories that I do not want to expose to the Internet. docker_opengist: I am a big fan of Github Gists, and discovered OpenGist, which is basically the self-hosted version. I put a lot of code snippets, scripts, and reference files here, and occasionally I will expand a gist into an Obsidian note, or a post on this blog! dav: I self-host my contacts and calendars, and synchronize them with my email client. I primarily use Radicale, but have recently been test driving Davis and am liking it a lot. games: I host a bunch of game servers for my friends and me. We connect over a private network I host to play. monitoring_alerting: I have tried many different monitoring/alerting solutions, and have kept a template for each one in this path. I used Zabbix for a while, but more recently I have used Beszel for server metric monitoring, Uptime Kuma for uptime checks, happyDomain to track and monitor my domains and DNS records, and NtopNG for local network monitoring. I run Uptime Kuma from a VPS so it can watch my homelab for outages. docker_ntfy/docker_gotify: My own self-hosted push notification servers. Ntfy and Gotify work very similarly, and I have been test driving both for a while. I can\u0026rsquo;t decide which one I prefer, and I don\u0026rsquo;t see much harm in using both. I\u0026rsquo;ve recently added Apprise into the mix, which lets me send notifications to both services. ","permalink":"/posts/200-docker-compose-templates/","summary":"\u003cp\u003e\u003cimg alt=\"200 templates\" loading=\"lazy\" src=\"/posts/200-docker-compose-templates/200-compose-templates.png#center\"\u003e\u003c/p\u003e\n\u003cp\u003eI have been maintaining a git monorepo named \u003ca href=\"https://github.com/redjax/docker_templates\"\u003e\u003ccode\u003edocker_templates\u003c/code\u003e\u003c/a\u003e since sometime in 2023, and I recently added my 200th template \u003ca href=\"https://github.com/redjax/docker_templates/tree/7c112b7222d81330c0310ec851c7b512f48347e6\"\u003e(commit \u003ccode\u003e7c112b7\u003c/code\u003e)\u003c/a\u003e! I thought back to 2015, when I started learning Docker and Docker Compose, and how transformative containers have been in the way I use my machines. There is something very satisfying about describing a runtime you want to work with and encapsulating everything you need to run an app or service the same way each time you run it, on any machine. In reality, there are some edge cases, but it\u0026rsquo;s a beautiful dream.\u003c/p\u003e","title":"200 Docker Compose Templates"},{"content":"I have been using Google Photos to backup my pictures since it was released in May of 2015. The software works great, seamlessly backing up any picture or video I take on my phone and making it simple to share photos and albums with my friends.\nLike many people, I am trying to reduce my reliance on Google products. I have not gone as hard as some people, like the users of the DeGoogle subreddit, although I admire their efforts and agree with their philosophy. I have so, so many things tied to the Google/Gmail account I created in 2005. A younger me had no concept of being the product when a service is free, and I foolishly tied so much of my online life to my Gmail account. Extricating is a long, difficult process, and one I\u0026rsquo;m doing in baby steps.\nThe AI craze has been just the push I needed to take my privacy more seriously. Companies are changing their Terms of Service, shoving AI hamfistedly into every corner of their product suite, trampling privacy and protection, and will not face any consequences for it in the foreseeable future. While it is too late to keep anything I\u0026rsquo;ve currently handed over to another entity to hold (like my pictures and personal information), I can limit further damage by pulling my most sensitive pieces of data back under my own control, and my photos felt like a good starting point.\nReasons for Leaving Google Docs: Gemini features in Photos privacy hub states they do not use your photos to train, but the capability is there and I don\u0026rsquo;t trust them to resist temptation. And whether or not they directly access my photos, enough of my life is wrapped up in Google\u0026rsquo;s products that I don\u0026rsquo;t believe they\u0026rsquo;d even need direct access to my pictures to abuse my privacy.\nThere are also horror stories, like the one where a whole house/family lost access to their Google accounts because of 1 user\u0026rsquo;s data. There is also the fact that Google quietly enabled photo scanning on user devices, and made it opt-out by default, an evil pattern we did not react strongly enough to a decade ago.\nAdditionally, you could potentially lose access to your account simply because Google decides your pictures are dangerous, without a care for context and with no recourse.\nWith how sensitive my Google account is, and with how much I currently rely on them, anything I can do to pull my data back into my own control is worth the effort. I researched a few products (hosted and self-hosted, free and paid), and while I did find some promising options, I ultimately settled on 1 self hosted piece of software and a backup plan that I control.\nAlternatives to Google Photos I started by looking into other hosted options, and the only one I would feel comfortable using is Ente photos. Ente is an open source company with a strong focus on privacy. They offer 10GB of free hosting (which is nowhere near enough for my library, but is a generous free tier), and the photos are end-to-end encrypted, meaning they can\u0026rsquo;t see my photos even if they want to. I am comfortable with that level of control over my own data while hosting it on someone else\u0026rsquo;s servers. I use some of Ente\u0026rsquo;s other products and I think they do great work, and for someone who does not want to go through the effort of hosting it themselves, I think Ente is one of the best, most reasonably-priced alternatives to Google Photos there is.\nI also evaluated 2 self-hosted solutions: PhotoPrism and Immich. This comparison between self-hosted photo apps came in handy while I was looking over alternatives.\nPhotoPrism is more intended for a single user, and for automated/(local) AI-assisted operations on your photos. It has great reviews, it\u0026rsquo;s been around for a little while and has many users, and setup with Docker looks relatively simple.\nImmich can perform many of the same functions as PhotoPrism, automatically recognizing faces and locations, tagging things, sorting by location, etc, but it\u0026rsquo;s also geared more towards multiple users. And while I am currently the only user of my photo suite, the potential to allow family or friends to host their photos in my library is appealing.\nThe winner: Immich Immich is backed by Futo, a company that makes local, privacy-respecting AI tools. They have a host of features I care about, and their Android app works great.\nMoving my photos out of Google Photos and into Immich was also relatively painless. Below I detail the steps in detail, but I was able to use Google Takeout to export my photos, and the immich-go CLI to import them into my Immich server. After that, I merely had to install the Immich app, sign into my server, and turn on backups from my phone.\nMoving to Immich I created a Docker Compose template to containerize the application. On a machine where I want to run my Immich stack, I use git\u0026rsquo;s sparse checkout feature to download only the media/docker_immich directory:\nFirst, clone the repository without checking out a branch: git clone --no-checkout https://github.com/redjax/docker_templates docker_immich cd docker_immich Initialize the sparse checkout: git sparse-checkout init --cone Set the checkout path: git sparse-checkout set templates/media/docker_immich Checkout the main branch: git checkout main Bring the whole stack up with:\n1 2 3 4 5 6 docker compose \\ -f compose.yml \\ -f overlays/postgres.yml \\ -f overlays/redis.yml \\ -f overlays/caddy.yml \\ up -d The -f overlays/service-name.yml syntax is for merging Docker Compose files. Running docker compose \u0026lt;command\u0026gt; without any additional -f paths runs only the services in compose.yml. These overlays provide Immich with a database, web server, and cache. If I am using a database or redis cache on another host, I would omit the -f overlays/{postgres,redis}.yml files and provide connection details for the remote.\nExport Google Photos Google offers a feature for exporting your Google Photos (and more account/app data) with Google Takeout. Using this feature, I exported ~200GB of photos and videos I\u0026rsquo;ve taken over the years into a .zip archive.\nNetworking I use Pangolin as a reverse proxy for my services. This allows me to route web traffic incoming to https://immich.mydomain.com to the Immich container running on a machine in my homelab. When protecting my site with Pangolin\u0026rsquo;s SSO authentication, I found I could not reach the URL when using the Immich Android app. I found this answer on Github that shows the custom request headers required to enable passing an access token.\nAfter setting up Immich as an HTTPS resource in Pangolin, create a shareable link and set the new Immich service as the resource. Set this link to \u0026ldquo;never expire\u0026rdquo; for convenience; if you set an expiration date, you will need to update the app with a new link each time it expires.\nOn the next screen, scroll past the QR code and copy the link below it. This is the link you will provide to the Android app. Click the Usage Examples button and note the P-Access-Token-Id and P-Access-Token values.\nYou will use this link and the access token to allow the Immich app to communicate with the server behind the Pangolin proxy.\nSetup Android App In the Immich Android app, navigate to Settings \u0026gt; Advanced \u0026gt; Custom proxy headers, and click \u0026ldquo;Add new header.\u0026rdquo; Name the header P-Access-Token-Id and paste the value from when you created the token. Create another header named P-Access-Token.\nThis setup authenticates requests from the Android app to the Pangolin proxy, then passes the traffic to the Immich server.\nUpload Photos I uploaded the collection I exported with Google Takeout to Immich using the immich-go CLI tool. First, I had to create an API key in Immich with the following permissions:\nasset.read asset.statistics asset.update asset.upload asset.copy asset.replace asset.delete asset.download album.create album.read albumAsset.create server.about stack.create tag.asset tag.create user.read job.create job.read I also created an admin key in Immich from the administrator account, with full permissions. The immich-go CLI uses the admin token to pause Immich jobs during upload. Othewrise, the regular API key will be used for operations. I created a .env file to store these values:\n1 2 3 4 export IMMICH_SERVER_URL=https://photos.mydomain.com export IMMICH_KEY=\u0026lt;Immich user key\u0026gt; export IMMICH_ADMIN_KEY=\u0026lt;Immich admin key\u0026gt; export IMMICH_LOCAL_PHOTOS=\u0026#34;/path/to/google-takeout/Takeout/Google Photos/\u0026#34; I wrote a script so I could repeat this upload process in the future if needed. Before running the script, I source the .env file with . .env, exporting the Immich variables to my environment.\nThis is the script I used to upload my photos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 #!/usr/bin/env bash set -euo pipefail if ! command -v immich-go \u0026gt;\u0026amp;/dev/null; then echo \u0026#34;[ERROR] immich-go is not installed.\u0026#34; \u0026gt;\u0026amp;2 exit 1 fi IMMICH_URL=\u0026#34;${IMMICH_SERVER_URL:-}\u0026#34; IMMICH_KEY=\u0026#34;${IMMICH_KEY:-}\u0026#34; IMMICH_ADMIN_KEY=\u0026#34;${IMMICH_ADMIN_KEY:-}\u0026#34; PHOTO_DIR=\u0026#34;${IMMICH_LOCAL_PHOTOS:-}\u0026#34; ## Trim trailing slashes PHOTO_DIR=\u0026#34;${PHOTO_DIR%/}\u0026#34; DRY_RUN=\u0026#34;false\u0026#34; function usage() { cat \u0026lt;\u0026lt;EOF Usage: ${0} [OPTIONS] Options: -h, --help Print this help menu -u, --server-url Immich server URL -k, --api-key Immich API token -K, --admin-key Immich API token with admin privileges -p, --local-path Path to local photos directory --dry-run Describe actions without taking them EOF } ## Parse CLI arguments while [[ $# -gt 0 ]]; do case $1 in -h | --help) usage exit 0 ;; -u | --server-url) IMMICH_URL=\u0026#34;${2}\u0026#34; shift 2 ;; -k | --api-key) IMMICH_KEY=\u0026#34;${2}\u0026#34; shift 2 ;; -K | --admin-key) IMMICH_ADMIN_KEY=\u0026#34;${2}\u0026#34; shift 2 ;; -p | --local-path) PHOTO_DIR=\u0026#34;${2}\u0026#34; shift 2 ;; --dry-run) DRY_RUN=\u0026#34;true\u0026#34; shift ;; *) echo \u0026#34;[ERROR] Invalid arg: $1\u0026#34; \u0026gt;\u0026amp;2 usage exit 1 ;; esac done ## Validate inputs [[ -z \u0026#34;${IMMICH_URL}\u0026#34; ]] \u0026amp;\u0026amp; echo \u0026#34;[ERROR] Missing --server-url\u0026#34; \u0026gt;\u0026amp;2 \u0026amp;\u0026amp; usage \u0026amp;\u0026amp; exit 1 [[ -z \u0026#34;${IMMICH_KEY}\u0026#34; ]] \u0026amp;\u0026amp; echo \u0026#34;[ERROR] Missing --api-key\u0026#34; \u0026gt;\u0026amp;2 \u0026amp;\u0026amp; usage \u0026amp;\u0026amp; exit 1 [[ -z \u0026#34;${PHOTO_DIR}\u0026#34; ]] \u0026amp;\u0026amp; echo \u0026#34;[ERROR] Missing --local-path\u0026#34; \u0026gt;\u0026amp;2 \u0026amp;\u0026amp; usage \u0026amp;\u0026amp; exit 1 if [[ ! -d \u0026#34;${PHOTO_DIR}\u0026#34; ]]; then echo \u0026#34;[ERROR] Could not find local photos dir: ${PHOTO_DIR}\u0026#34; \u0026gt;\u0026amp;2 exit 1 fi cmd=(immich-go upload from-google-photos -s \u0026#34;$IMMICH_URL\u0026#34; -k \u0026#34;$IMMICH_KEY\u0026#34; --on-errors continue) if [[ -n \u0026#34;${IMMICH_ADMIN_KEY}\u0026#34; ]]; then cmd+=(--admin-api-key \u0026#34;${IMMICH_ADMIN_KEY}\u0026#34;) else cmd+=(--pause-immich-jobs=FALSE) fi ## Append photo dir to end of command cmd+=(\u0026#34;$PHOTO_DIR\u0026#34;) if [[ \u0026#34;$DRY_RUN\u0026#34; == \u0026#34;true\u0026#34; ]]; then echo \u0026#34;[DRY RUN] Running immich-go in dry-run mode\u0026#34; cmd+=(--dry-run) fi echo \u0026#34;Starting upload from $PHOTO_DIR to server: $IMMICH_URL\u0026#34; if ! \u0026#34;${cmd[@]}\u0026#34;; then echo \u0026#34;[ERROR] Failed uploading photos\u0026#34; \u0026gt;\u0026amp;2 exit 1 fi echo \u0026#34;Upload complete\u0026#34; If I ever need to re-import my collection, or if I create a new Google Takeout with my photos, I can use this script to upload them to my Immich server. The tool works great, it even imported my albums and all of the EXIF data from Google.\nTradeoffs Immich feels like a true replacement for Google Photos. The app is familiar enough if you\u0026rsquo;ve used Google Photos. It has many of the same features, like duplicate and face detection, searches for objects like \u0026ldquo;dog\u0026rdquo; or \u0026ldquo;tree,\u0026rdquo; and creating shareable links for photos, videos, and galleries. But there are several real tradeoffs you need to be aware of, and risks you need to accept, when moving from a hosted photo provider to your own self-hosted setup.\nSecurity The first and biggest concern is security. A large company like Google has an interest in protecting what you upload to their servers. While they may use this data in objectionable ways, it is undeniably more secure to entrust your memories to a corporation with a dedicated security team and another team of engineers improving the service over time. While Google is famous for abruptly killing useful services, when you self host your media, you are also responsible for how you expose the server to the world (if you do this at all), and protecting the resources from active and passive attacks, and for ensuring your authentication layer and firewall keep unsavory traffic out of your systems.\nBackups The next important factor to consider is backups. When you host your own media collection, you will want to ensure memories aren\u0026rsquo;t lost from hardware failures or containerization problems. Keeping backups is crucial, and entirely your responsibility. When backing up important data, you should follow the \u0026ldquo;3-2-1 backups\u0026rdquo; principle, which boil down to:\nKeep 3 copies of your data: the original data on your primary device, and at least 2 copies. Use 2 different storage devices: a PC, external disk, USB flash drive, NAS or SAN, or cloud storage services; pick any 2. Keep 1 backup copy off-site: always have a copy of the backup somewhere other than where all of the main data and backups are. For example, if you are backing up to an external drive and a NAS on your home network, keeping a backup copy in an encrypted S3 bucket will prevent total data loss due to damage that might occur at home, like a fire/flood or heat damage. This strategy can be intense for a homelab, and it increases the size your collection uses on-disk. I generally keep 1 copy somewhere local (an external drive or my NAS), and upload an encrypted copy to Wasabi cloud storage. If you want to avoid cloud hosting, you could also do a \u0026ldquo;buddy backup,\u0026rdquo; which is where you keep a storage device at a \u0026ldquo;buddy\u0026rsquo;s\u0026rdquo; location (a relative or friend\u0026rsquo;s house, or rented networked storage at a local datacenter, etc).\nI use Restic for backups, and which makes it simple to keep a local backup, a backup on another machine on my LAN via Rclone SFTP copy, and a remote copy in pCloud or Wasabi S3.\nMaintenance Finally, there is the maintenance cost to consider. Comparing again to a hosted service, the company will have a dedicated team of engineers responsible for keeping hardware and software up to date, managing storage and quotas, and fixing things when they break. When you self-host, you become a 1-person maintenance team. The upside of choosing your own SLA is that you choose your own SLA; the downside is that shit\u0026rsquo;s broken until you fix it. And if you experience a hardware failure, you also become the data recovery team. This can be a burden, but tools like Dockhand can simplify maintenance operations.\nConclusion Pulling my pictures and videos out of Google Photos felt great. I have all of my media on a drive in my house, backed up in multiple places where I feel comfortable I won\u0026rsquo;t lose my collection due to an errant account ban or deletion, and I know my media isn\u0026rsquo;t being ingested and mixed training data for an AI to use. I have full control of my media, accepting the tradeoffs in maintenance and backup responsibilities to have freedom from a service I grow continually displeased with, and do not trust to continue providing the service without interruption or deprecation on a whim. And best of all, I no longer need to pay Google a subscription to hold my growing photo collection. Goodbye, Google One!\n","permalink":"/posts/leaving-google-photos/","summary":"\u003cp\u003eI have been using Google Photos to backup my pictures since it was released in May of 2015. The software works great, seamlessly backing up any picture or video I take on my phone and making it simple to share photos and albums with my friends.\u003c/p\u003e\n\u003cp\u003eLike many people, I am trying to reduce my reliance on Google products. I have not gone as hard as some people, like the users of the \u003ca href=\"https://reddit.com/r/degoogle\"\u003eDeGoogle subreddit\u003c/a\u003e, although I admire their efforts and agree with their philosophy. I have so, so many things tied to the Google/Gmail account I created in 2005. A younger me had no concept of being the product when a service is free, and I foolishly tied so much of my online life to my Gmail account. Extricating is a long, difficult process, and one I\u0026rsquo;m doing in baby steps.\u003c/p\u003e","title":"Leaving Google Photos"},{"content":"Having a good backup strategy is essential, especially when you host your own data. If you have not experienced the dread of realizing you\u0026rsquo;ve lost an important file, consider yourself lucky and keep reading to learn why you should have a backup strategy for your data. I have lost important data due to lack of sufficient backups a number of times in my life. It never gets any less devastating, but each time it\u0026rsquo;s happened I have inched closer to a sufficient backup strategy.\nThere are a number of great solutions out there, from Kopia, Duplicati and Borg, to good ol\u0026rsquo; rsync and a remote share, and I have tried most of them. Let\u0026rsquo;s talk about them, and why I ended up using Restic for all of my backups.\nThe Problem For a long time, I used Bash scripts scheduled with cron to create .tar.gz archives of important directories in a directory on my machine, i.e. /opt/backups. I eventually started using the rsync CLI tool to copy the backups via SSH to a central machine with a few extra hard drives.\nThese scripts got unwieldy, and I\u0026rsquo;ve lost most of them because I was not using version control yet. I also use both Windows and Linux, and translating my backup scripts to Powershell from Bash was good practice, but tedious.\nThe Journey I eventually started looking for tools I could install, and was pleased to find a ton of different open source projects with varying approaches to backups. I tried a bunch of them, feeling more and more like Goldilocks as I found one solution too resource intensive, another too complicated, some were too unreliable and others difficult to navigate. In my quest to find the perfect solution for my needs, the following utilities felt closest to my nebulous idea of the perfect solution for my homelab.\nDuplicati + Wasabi I started with Duplicati, which I used to create scheduled backups to my NAS and Wasabi S3 storage. Creating the scheduled jobs was easy, and there is a management webUI that made creating and monitoring the backups pretty easy.\nI was able to avert a couple of data loss events by restoring from a Duplicati backup, but I ended up running into database corruption a time or two (a known issue with Duplicati, if you search \u0026ldquo;Duplicati database corrupted\u0026rdquo;). You can recover from this state, but after the 2nd time it happened, I started looking for a different solution.\nTip Duplicati Pros:\nWebUI was a nice way to manage the backups graphically. Scheduling jobs with retry logic was easy. Multiple backends, from local storage to cloud buckets to webDAV. Duplicati Cons:\nSlow backups, slow restores. Restore operations less reliable. More prone to database corruption. Manual restore process is tedious. Functional webUI, not winning any style awards. Kopia I really liked being able to add a Wasabi bucket as one of the backup destinations, and my searches for an alternative to Duplicati eventually lead me to try Kopia. I had read forum posts of people moving to Kopia from Duplicati, and I ran the two side-by-side for a while to get a feel for Kopia.\nIt did not take long to drop Duplicati entirely. It took me a little while to get used to having only 1 repository for backups, and I never got around to setting up a Kopia server to allow for more. The way I used Kopia was essentially a per-machine repository.\nKopia worked well for me, and I would still recommend it as an option. The UI was the biggest pain point for me: the CLI commands were long and hard to remember, and the webUI was spartan and at times unintuitive.\nTip Kopia Pros:\nFairly easy to pick up and understand. Reliable scheduled backups, faster than Duplicati, and the resulting snapshots were smaller. Different apps for different needs (a CLI, an optional web interface, and a desktop app). Test data restores worked flawlessly every time, and Kopia allowed for restoring to a destination, or in-place (restoring directly back to the path where the original file/directory was). Kopia Cons:\nThe webUI, while better than Duplicati, still left something to be desired. While Kopia has a ton of features, the complexity could be difficult to navigate. Borg Backup I will be honest, I probably didn\u0026rsquo;t give Borg enough time to write an honest review about it. I started using it essentially in tandem with picking up restic, and quickly gravitated to restic.\nI love Borg in theory, it\u0026rsquo;s a tool known for its relative ease of use and reliability when it comes time to restore from a backup. In comparing Borg and restic, I found a helpful Reddit thread comparing the two tools, and a few of the points were enough for me to move fully to restic. Below are the direct quotes from the post that swayed me (in case the link rots over time):\nRestic\u0026rsquo;s cryptography is much better because it has been endorsed by one of Google\u0026rsquo;s cryptography experts that wrote the crypto library for Google\u0026rsquo;s Go language. He ended up choosing Restic as his personal backup system after the investigation. Borg\u0026rsquo;s cryptography has many security flaws and they\u0026rsquo;re working on a rewrite of it for the next 1.3+ release named \u0026ldquo;Helium\u0026rdquo;. \u0026hellip;truncated Borg 2.0 addresses these concerns, but is still in beta as of February 2026 and not recommended for production use. Borg requires that the receiver runs Borg on the server, which limits it to rsync.net and borgbase.com for online cloud storage. \u0026hellip;truncated Hetzner storage boxes are also supported now, but the number of available backends for Borg is still limited. Arriving at Restic As I researched and tried different backup solutions, I kept seeing comments and posts about restic. I learned about people scripting restic with Bash like I had done with rsync.\nRestic has powerful deduplication with their Content Defined Chunking (CDC) implementation, leading to smaller backups and better accuracy.\nRestic also works with many different backends, and can use Rclone to expand backup destination options even more.\nWith restic, you create a repository (it\u0026rsquo;s easy to create and use multiple repositories stored in different backends), point the program at a path, give it an excludes file (or an includes file), and let it rip. It\u0026rsquo;s pretty much that easy.\nI have tested Restic with an external SSD, a directory on another machine (via SSH, facilitated by Rclone), pCloud (also via Rclone), and a Wasabi S3 bucket. Restic does a great job of making all of this feel insignificant and simple; it doesn\u0026rsquo;t matter if your backup is on the same machine or a cloud bucket in another region of the world, what matters is the contents and integrity of the backup, which Restic handles gracefully.\nRestic makes it easy to explore your snapshots, and in my tests restoring data is quick, simple, and \u0026ldquo;just works.\u0026rdquo; You can also script Restic operations, although I never dove too deep into this.\nInstead, I found two other tools that cemented my choice to use restic for my backups: Resticprofile and Backrest.\nTip Restic pros:\nBackup speed, especially during incremental backups. Powerful deduplication saves space. Great 3rd party tools like resticprofile and backrest. Good mix of security and usability. Restic cons:\nThere is a slight learning curve. Command chains can feel unnatural at first, especially if you\u0026rsquo;re coming from a GUI/webUI application. Restic on its own can feel cumbersome, with long commands and a lot of args. Extra tooling like Resticprofile or Backrest helps significantly with this. Resticprofile Resticprofile is a wrapper for restic that works by reading backup configurations from a file. You can define defaults, the file supports inheritance, templates, and template variables; you can \u0026ldquo;group\u0026rdquo; individual profiles which you can call with resticprofile -n \u0026lt;profile-name\u0026gt;. After defining your backups, you can run resticprofile schedule install to add scheduled tasks (systemd on Linux, Task Scheduler on Windows, Launchd on macOS), which fully automates your backups. It can monitor backup status using a JSON file or by sending metrics to Prometheus.\nRestic and Resticprofile being cross-platform, with support for Linux, Windows, and macOS, meant I could use the same app and portions of the same configuration file across my machines. Automating the backups meant I did not have to write custom, per-platform scripts to run the backups on a schedule.\nBackrest I also add Backrest to some of my servers, which provides a webUI I can use instead of logging into the machine directly. Backrest can import and use existing repositories, and the UI allows for the same level of orchestration as resticprofile or the restic CLI. You can browse snapshots and restore files in the webUI, add notifications to Discord, Slack, Gotify, etc, and it has pre/post command hooks to execute shell scripts. I generally use either Backrest or resticprofile on a given machine, as they both serve essentially the same function. But since they can both use the same backend repository, it\u0026rsquo;s easy to switch them out on demand if I decide I want to manage backups using one or the other.\nBackrest also has a Docker container, which makes it a great addition to Docker Compose stacks for automating container data backup.\nConclusion I was aware of restic for a while before I actually decided to try it, and I can say I wish I had tried this tool sooner. While there\u0026rsquo;s a slight learning curve, once you get your first backup running, and especially after using a tool to automate the complexity like resticprofile or backrest, it\u0026rsquo;s clear to me that restic is the perfect combination of user friendliness, security, reliability, and simplicity for a backup solution. I will write another post or two describing how I actually use restic with examples of a resticprofile configuration file and some dos and don\u0026rsquo;ts.\n","permalink":"/posts/comparing-backup-solutions/","summary":"\u003cp\u003eHaving a good backup strategy is essential, especially when you host your own data. If you have not experienced the dread of realizing you\u0026rsquo;ve lost an important file, consider yourself lucky and keep reading to learn why you should have a backup strategy for your data. I have lost important data due to lack of sufficient backups a number of times in my life. It never gets any less devastating, but each time it\u0026rsquo;s happened I have inched closer to a sufficient backup strategy.\u003c/p\u003e","title":"Comparing backup solutions (why I picked Restic)"},{"content":"This post is part of a series: Blog Setup.\nI also faced some difficulties getting started with the blog. I spent way too much time creating the Github Action that deploys my content to Netlify. I failed so many times before getting it right, I exceeded Netlify\u0026rsquo;s free tier 🤡. Most of the difficulty actually came from the \u0026ldquo;extra\u0026rdquo; functionality with Lychee link checking and Vale linting. Although I was able to get both of these tools running well locally on my machine, I ran into challenges getting them to run in the pipeline.\nLinting with Vale was very difficult to nail down. I was able to get it to work reliabily in the local repository, but struggled with containerizing it and running in a pipeline. I also had invalid syntax in my Lychee link scanning for a while. I resolved both issues, but definitely got caught up in the weeds and did not write any new blog posts in the meantime.\nI reposted [one of my old blogs from Medium](/posts/linux-frustrated-lovingI don\u0026rsquo;t), and realized Hugo doesn\u0026rsquo;t have a great way of centering images. You can use Hugo shortcodes, but those are specific to/dependent on Hugo, and in the interest of keeping the content as decoupled from the site generator as possible, I did not want to invest too heavily in shortcodes. I found a really helpful blog post on ebaddf.net that had a simple, elegant fix that should work across site generators.\nIt involves creating a custom CSS file, i.e. at static/css/custom.css:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 img[src$=\u0026#39;#center\u0026#39;] { display: block; margin: 0.7rem auto; /* you can replace the vertical \u0026#39;0.7rem\u0026#39; by whatever floats your boat, but keep the horizontal \u0026#39;auto\u0026#39; for this to work */ /* whatever else styles you fancy here */ } img[src$=\u0026#39;#floatleft\u0026#39;] { float:left; margin: 0.7rem; /* this margin is totally up to you */ /* whatever else styles you fancy here */ } img[src$=\u0026#39;#floatright\u0026#39;] { float:right; margin: 0.7rem; /* this margin is totally up to you */ /* whatever else styles you fancy here */ } And embedding images using that class:\n1 2 3 ![your_img](/img/your_img.png#center) ![your_img](/img/your_img.png#floatleft) ![your_img](/img/your_img.png#floatright) Getting URL slugs to work for content nested in directories by year (i.e. content/posts/\u0026lt;year\u0026gt;/post-name.md in the source code) to work proved harder than I expected. I wanted to organize the source code for my blogs by year (both as a way to reduce clutter/page loads in Github, and because it\u0026rsquo;s easier to manage in a code editor), but I wanted the URLs to omit the year (i.e. /posts/post-name without the year).\nI tried a few different things:\nUsing the cascade configuration option to \u0026ldquo;collapse\u0026rdquo; URLs This was supposed to move anything in subdirectories under content/posts/** up to the root posts/ path when building the site. It worked, but it also caused /posts/ to return a 404 error. Creating a branch bundle with content/posts/\u0026lt;year\u0026gt;/_index.md. This solution also made use of the cascade option, but in a more targeted way:\n1 2 3 4 --- cascade: url: \u0026#34;/posts/:slug/\u0026#34; --- I originally used headless: true, but this option contributed to my 404 problem, because I use /posts to aggregate all of my posts.\nAdding a slug to each page. I tried adding slug: \u0026quot;url-slug-i-want\u0026quot;. I even added it to my posts archetype This apparently has no effect on bundles. I found that using a slug to manually set the URL path works well. For some pages, the url option worked better. slug is for overriding the last segment of the path, i.e. /page-name/slug-name, while url gives full control over the URL path, even allowing characters that are reserved by the OS, i.e. adding colons : to a URL like http://example.com/my:example.\nIf both slug and url are set, the url parameter takes precedence\nFor posts, this looks like:\n1 2 3 4 5 6 7 8 # archetypes/posts.md --- title: \u0026#34;{{ replace .Name \u0026#34;-\u0026#34; \u0026#34; \u0026#34; | title }}\u0026#34; date: {{ .Date }} draft: true slug: \u0026#34;/post-name/optional-sub-post-name\u0026#34; ## Settinng a URL too will override the slug url: \u0026#34;post-name/optional-sub-post-name\u0026#34; I am sure there will be many other challenges (for instance, I have not tried changing the blog\u0026rsquo;s theme yet, and that looks like a whole adventure if you rely on theme features), but the community forums are filled with very helpful threads, and it is relatively easy to find help for Hugo online. It\u0026rsquo;s a popular tool that\u0026rsquo;s well documented and has an active community. Some of the challenges I\u0026rsquo;ve faced are quirks or limitations of Hugo, while some are problems I would have had with any static site generator. I\u0026rsquo;m overall happy with the Hugo experience.\n","permalink":"/posts/blog-setup/challenges/","summary":"\u003cp\u003e\u003cem\u003eThis post is part of a series: \u003ca href=\"/series/blog-setup\"\u003eBlog Setup\u003c/a\u003e.\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eI also faced some difficulties getting started with the blog. I spent way too much time creating the Github Action that deploys my content to Netlify. I failed so many times before getting it right, I exceeded Netlify\u0026rsquo;s free tier 🤡. Most of the difficulty actually came from the \u0026ldquo;extra\u0026rdquo; functionality with Lychee link checking and Vale linting. Although I was able to get both of these tools running well locally on my machine, I ran into challenges getting them to run in the pipeline.\u003c/p\u003e","title":"Blog Setup: Challenges"},{"content":"This post is part of a series: Blog Setup.\nTools I am using Mise to handle installing all of the tools I need to develop/build the blog. The .mise.toml file in the repository defines these tools, so I can simply clone the repository, install Mise, and run mise trust \u0026amp;\u0026amp; mise install on a new machine to get up and running. Mise is also useful in container environments and CI/CD pipelines, it can ensure I\u0026rsquo;m installing the same tools in every environment, with version pinning for ones I want to control upgrades for like the hugo CLI.\nTaskfile is a new tool I found that lets you write YAML files to define tasks that can be called with the task CLI. It\u0026rsquo;s a very versatile tool, and because it uses an embedded shell, it\u0026rsquo;s (pretty much) fully cross platform. This is very useful, because it means the same tasks can run on Windows, Mac, or Linux, in CI/CD pipelines, Dockerfiles, etc. I use it for simplifying some commands I run frequently, like Docker commands or Hugo commands.\nVale is a utility that lets you define a style guide and lint text content against it. You can use predefined styles, like Microsoft or Google\u0026rsquo;s, and can mix and match. When Vale runs, it will surface writing and readability issues from the chosen style guides.\nLychee is a fast, easy to use link checker. I found it while setting up this blog, tried it once, and immediately added it to the stack. Lychee can scan live sites/URLs, but can also be pointed at the raw Markdown files that comprise this blog, or the rendered HTML. This allows me to catch broken links on the site before deploying, or to detect linkrot.\n","permalink":"/posts/blog-setup/tools/","summary":"\u003cp\u003e\u003cem\u003eThis post is part of a series: \u003ca href=\"/series/blog-setup\"\u003eBlog Setup\u003c/a\u003e.\u003c/em\u003e\u003c/p\u003e\n\u003ch2 id=\"tools\"\u003eTools\u003c/h2\u003e\n\u003cp\u003eI am using \u003ca href=\"https://mise.jdx.dev\"\u003eMise\u003c/a\u003e to handle installing all of the tools I need to develop/build the blog. The \u003ca href=\"https://github.com/redjax/blog/blob/main/.mise.toml\"\u003e\u003ccode\u003e.mise.toml\u003c/code\u003e file in the repository\u003c/a\u003e defines these tools, so I can simply clone the repository, install Mise, and run \u003ccode\u003emise trust \u0026amp;\u0026amp; mise install\u003c/code\u003e on a new machine to get up and running. Mise is also useful in container environments and CI/CD pipelines, it can ensure I\u0026rsquo;m installing the same tools in every environment, with version pinning for ones I want to control upgrades for like the \u003ccode\u003ehugo\u003c/code\u003e CLI.\u003c/p\u003e","title":"Blog Setup: Tools"},{"content":"This post is part of a series: Blog Setup.\nWhile setting up the blog I had to make a number of choices that affect the short and long term health of the blog. Deciding where and how to host the code and the static site, what tooling to add, how to structure the repository, etc.\nGit Forge Spoiler: I went with Github.\nI considered many options for where to host the source code for the blog. Github is an obvious frontrunner, partially becaause I am already active on Github, but also the network effect. While I don\u0026rsquo;t promote my blog, I also like the idea that I\u0026rsquo;m not just shouting into the void with my posts, and discoverability is best where the people are.\nGithub hit one billion repositories on June 11, 2025, the first public forge to do so, highlighting the sheer amount of attention and usage on the platform. Most of my coding activity is still on Github (although I mirror many of my repositories to codeberg.org), so anyone stopping by my profile is more likely to find my blog.\nOne of my concerns while setting up the blog was the ability to quickly and easily move to a new Git host. If I want to move the blog to Gitlab or my own self hosted git forge, I don\u0026rsquo;t to have to labor to get back up and running. I plan to only dip my toes into a given platform; I will take advantage of some of the features, but will prefer external or embedded solutions for most things (sidenote: I need to find a way to keep issues out of Github.)\nAnd so Github it is, for now.\nCI/CD I spent time planning the structure of the git repository for the blog to make it portable. I have seen a number of blogs switch platforms over the years, and the posts they write at the end of a migration have inspired some of the choices made for this blog.\nFor example, while this project is hosted on Github and I am using Github Actions to do things like lint the content and publish new pages, I also recreated the functionality in Concourse CI pipelines so I am not tied to a specific git platform. I could move the blog\u0026rsquo;s repository to any other remote, like Gitlab or Codeberg without breaking my CI/CD. At some point I will probably try writing the pipelines in Dagger to make them truly portable.\nI also considered Woodpecker CI, which is a fork of Drone, Crow CI which felt a bit too simplistic, and Komodo. I may still integrate Komodo for GitOps like deploying on specific conditions in a PR.\nHosting Note I moved this blog from Netlify to Cloudflare Pages on 2026-08-02. For a simple static site like this, a Netlify deployment ended up being overkill, and had the potential to run up billing, limiting my ability to publish the site and taking it offline completely until the next billing cycle.\nNetlify\u0026rsquo;s service was very easy to get started with, and I like the service. It offers a lot more than Cloudflare Pages, and this blog simply doesn\u0026rsquo;t need those \u0026ldquo;extras.\u0026rdquo; It made sense to switch to Cloudflare Pages, where I get unlimited deployments and free, simple hosting.\nAs of 01/24/2026, this blog is hosted on Netlify, a platform I have been looking for a reason to try. I disabled Netlify\u0026rsquo;s automated rebuilds on merges to main so I could write a deployment pipeline of my own, mainly for the experience, but also to ensure I don\u0026rsquo;t break the site when I\u0026rsquo;m trying new things. I hit Netlify\u0026rsquo;s free tier limit in 2 days because of all the pipeline failures\u0026hellip;I mean \u0026ldquo;tests\u0026rdquo; that I ran\u0026hellip;but the team was generous enough to refresh my credits when I reached out for support. +1 to Netlify!\nBecause the site is just static HTML/JS/CSS, I leave the option open to move the site\u0026rsquo;s hosting to basically anywhere. I build a production Docker image, so I could deploy a container somewhere and route the techobyte.cc domain to it, or simply copy the static files to any host that can serve files via a web server. This ensures I won\u0026rsquo;t get \u0026ldquo;stuck\u0026rdquo; with a specific host, or with a specific deployment method. I have heard of many bloggers who run their blogs off a Raspberry Pi they have in their house, and there\u0026rsquo;s really nothing stopping me from doing the same!\nI also evaluated Fly, but decided they were more geared at full blown application deployments, Azure Containers, a Hetzner VPS I would have to maintain, DigitalOcean Droplets, and of course hosting it myself on a machine in my home, behind a reverse proxy.\nTheme This was a tough one\u0026hellip; Hugo has a lot of themes. Most of them look good, some of them look great, but I had a few requirements in mind:\nOptimized for text-based posts. Most of my posts will be text-heavy, so optimizing layout for reading was an important factor. Simplicity, I didn\u0026rsquo;t want to use a highly customizable theme that would require a lot of configuration and tweaks. I am aware of my tendency to find yaks to shave, and want to focus on writing. Limiting myself to themes that are opinionated and rigid narrowed the selection pretty significantly. As generic as possible. Themes like Blowfish are amazing, but you have to go all-in to really take advantage. I am building this blog for the long term. The ability to switch themes/site generators is highly appealing. The more customization I need to do to the blog, the more I am building the site around the theme rather than the content. Content is front and center, no landing pages or superflous sections. Support RSS (this is officially supported by Hugo, but some themes handle revealing the functionality to readers better than others). I decided to start with PaperMod as my theme. While it is kind of opinionated and has some theme-specific configurations, they seem easy enough to \u0026ldquo;rip out\u0026rdquo; if I ever want to change it later. It has a light and dark mode, and center-aligns the content for distraction-free reading. RSS setup was easy, and the icon is very obvious on pages that support it.\n","permalink":"/posts/blog-setup/choices-made/","summary":"\u003cp\u003e\u003cem\u003eThis post is part of a series: \u003ca href=\"/series/blog-setup\"\u003eBlog Setup\u003c/a\u003e.\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eWhile setting up the blog I had to make a number of choices that affect the short and long term health of the blog. Deciding where and how to host the code and the static site, what tooling to add, how to structure the repository, etc.\u003c/p\u003e\n\u003ch2 id=\"git-forge\"\u003eGit Forge\u003c/h2\u003e\n\u003cp\u003eSpoiler: \u003ca href=\"https://github.com/redjax/blog\"\u003eI went with Github\u003c/a\u003e.\u003c/p\u003e\n\u003cp\u003eI considered many options for where to host the source code for the blog. Github is an obvious frontrunner, partially becaause \u003ca href=\"https://github.com/redjax\"\u003eI am already active on Github\u003c/a\u003e, but also the network effect. While I don\u0026rsquo;t promote my blog, I also like the idea that I\u0026rsquo;m not just shouting into the void with my posts, and discoverability is best where the people are.\u003c/p\u003e","title":"Blog Setup: Choices Made"},{"content":"Starting a blog has been an interest of mine for a long time. I have always envied people who began writing their tech blogs decades ago and still post to them today. I have written articles on Medium and other platforms over the years, but have never centralized them or made them \u0026ldquo;portable\u0026rdquo; in a meaningful way.\nI decided to finally start my blog, becaause each year that passes where I haven\u0026rsquo;t joined the ranks of nerd bloggers is another year I can\u0026rsquo;t look back on in this format.\nThis blog setup series chronicles the process of getting this blog up and running. I will release posts detailing tools I am using, choices I\u0026rsquo;ve made during setup, and challenges I faced being new to Hugo, the static site generator I\u0026rsquo;m using to build the blog. I may retroactively edit pages while the series is in progress.\nPosts Info This series is a work in progress. The list of posts below is incomplete until this message is removed.\nPart 1: Choices Made Part 2: Tools Part 3: Challenges ","permalink":"/posts/blog-setup/","summary":"\u003cp\u003eStarting a blog has been an interest of mine for a long time. I have always envied people who began writing their tech blogs decades ago and still post to them today. I have written articles on Medium and other platforms over the years, but have never centralized them or made them \u0026ldquo;portable\u0026rdquo; in a meaningful way.\u003c/p\u003e\n\u003cp\u003eI decided to finally start my blog, becaause each year that passes where I haven\u0026rsquo;t joined the ranks of nerd bloggers is another year I can\u0026rsquo;t look back on in this format.\u003c/p\u003e","title":"Blog Setup"},{"content":"If you have ever accidentally committed code under the wrong git.user/git.email, you should know you can rewrite the git log to change commit authors using git filter-branch.\nGit filter-repo plugin Install the git-filter-repo plugin for Git with Python:\n1 pip install git-filter-repo This plugin is required to run the git filter-repo command. It is ok to install this using \u0026ldquo;system Python.\u0026rdquo;\nIf you are running Linux, you can install it with apt install -y git-filter-repo (or whatever package manager your distribution uses, i.e. dnf for RedHat/Fedora).\nSteps Clone the repository that has the history you want to rewrite using the --bare flag:\n1 git clone --bare git@github.com:user/repo.git Run the following command to replace all instances of the old/wrong name \u0026amp; email with a different Git user:\n1 2 3 4 5 6 7 8 git filter-branch --env-filter \u0026#39; if [ \u0026#34;$GIT_COMMITTER_NAME\u0026#34; = \u0026#34;Old Name\u0026#34; ] \u0026amp;\u0026amp; [ \u0026#34;$GIT_COMMITTER_EMAIL\u0026#34; = \u0026#34;old.email@example.com\u0026#34; ]; then GIT_COMMITTER_NAME=\u0026#34;New Name\u0026#34; GIT_COMMITTER_EMAIL=\u0026#34;new.email@example.com\u0026#34; GIT_AUTHOR_NAME=\u0026#34;New Name\u0026#34; GIT_AUTHOR_EMAIL=\u0026#34;new.email@example.com\u0026#34; fi \u0026#39; --tag-name-filter cat -- --branches --tags Info Replace Old Name with the old git config user.name, old.email@example.com with the old git config user.email, and do the same for New Name and new.email@example.com.\nClean the reflog and run Git garbage collection to remove any cached history with the old Git user:\n1 2 git reflog expire --expire=now --all git gc --prune=now Finally, force push the changes back to the remote:\n1 2 git push --force --all git push --force --tags Tip If you have branch protection rules that prevent force pushing, you will need to turn them off temporarily to run the force push.\nVerify the rewrite succeeded by cloning the repository to a new path and search the log for the old user:\n1 2 3 4 mkdir ~/tmp git clone git@github.com:user/repo.git ~/tmp/repo cd ~/tmp/repo git log --author=\u0026#34;Old Name\u0026#34; You should not see any results; if you do, look back through the history of your commands to see if there were any errors during the process.\nBash script On a Linux or Mac system, you can use this Bash script to automate the steps above. Run the script with --help to see the usage menu.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 #!/usr/bin/env bash set -euo pipefail ## Find a Python interpreter for pip installs PYTHON_BIN=\u0026#34;\u0026#34; for bin in python3 python py py3 python; do if command -v \u0026#34;$bin\u0026#34; \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then PYTHON_BIN=$bin break fi done ## Ensure git-filter-repo is installed (try uv first, then pip) if ! command -v git-filter-repo \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;git-filter-repo not found.\u0026#34; ## Install with uv, if available if command -v uv \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;uv found. Installing git-filter-repo as a tool...\u0026#34; uv tool install git-filter-repo export PATH=\u0026#34;$HOME/.local/bin:$PATH\u0026#34; fi if [[ -z \u0026#34;$PYTHON_BIN\u0026#34; ]]; then echo \u0026#34;No Python interpreter found. Please install Python or uv.\u0026#34; exit 1 fi ## Fallback to Python echo \u0026#34;Using $PYTHON_BIN to install git-filter-repo via pip...\u0026#34; \u0026#34;$PYTHON_BIN\u0026#34; -m pip install --user git-filter-repo export PATH=\u0026#34;$HOME/.local/bin:$PATH\u0026#34; fi ## Test git-filter-repo was installed correctly if ! command -v git-filter-repo \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;git-filter-repo still not found after installation attempts.\u0026#34; exit 1 fi ## Function to print help menu/usage usage() { echo \u0026#34;\u0026#34; echo \u0026#34;Usage: $0 [--force] \\\\\u0026#34; echo \u0026#34; --repo-url git@github.com:user/repo.git \\\\\u0026#34; echo \u0026#34; --source-email \u0026#39;old.email@example.com\u0026#39; \\\\\u0026#34; echo \u0026#34; --target-email \u0026#39;new.email@example.com\u0026#39; \\\\\u0026#34; echo \u0026#34; [--source-name \u0026#39;Old Name\u0026#39;] \\\\\u0026#34; echo \u0026#34; [--target-name \u0026#39;New Name\u0026#39;]\u0026#34; echo \u0026#34;\u0026#34; exit 1 } ## Default vars REPO_URL=\u0026#34;\u0026#34; SRC_EMAIL=\u0026#34;\u0026#34; TGT_EMAIL=\u0026#34;\u0026#34; SRC_NAME=\u0026#34;\u0026#34; TGT_NAME=\u0026#34;\u0026#34; FORCE_PUSH=\u0026#34;\u0026#34; ## Parse arguments while [[ $# -gt 0 ]]; do case $1 in --repo-url) REPO_URL=\u0026#34;$2\u0026#34; shift 2 ;; --source-email) SRC_EMAIL=\u0026#34;$2\u0026#34; shift 2 ;; --target-email) TGT_EMAIL=\u0026#34;$2\u0026#34; shift 2 ;; --source-name) SRC_NAME=\u0026#34;$2\u0026#34; shift 2 ;; --target-name) TGT_NAME=\u0026#34;$2\u0026#34; shift 2 ;; --force) FORCE_PUSH=1 shift ;; -h | --help) usage ;; *) echo \u0026#34;Invalid argument: $1\u0026#34; usage ;; esac done if [[ -z \u0026#34;$REPO_URL\u0026#34; || -z \u0026#34;$SRC_EMAIL\u0026#34; || -z \u0026#34;$TGT_EMAIL\u0026#34; ]]; then echo \u0026#34;Missing required arguments.\u0026#34; usage fi echo \u0026#34;Repo URL: $REPO_URL\u0026#34; echo \u0026#34;Replacing source email \u0026lt;$SRC_EMAIL\u0026gt; with target email \u0026lt;$TGT_EMAIL\u0026gt;\u0026#34; if [[ -n \u0026#34;$SRC_NAME\u0026#34; ]]; then echo \u0026#34;Source name: $SRC_NAME\u0026#34; fi if [[ -n \u0026#34;$TGT_NAME\u0026#34; ]]; then echo \u0026#34;Target name: $TGT_NAME\u0026#34; fi ## Create temporary directory to clone repo into TMP_DIR=$(mktemp -d) echo \u0026#34;Mirror cloning repository into temporary directory: $TMP_DIR\u0026#34; git clone --mirror \u0026#34;$REPO_URL\u0026#34; \u0026#34;$TMP_DIR/repo\u0026#34; cd \u0026#34;$TMP_DIR/repo\u0026#34; echo \u0026#34;Rewriting commit history emails with git-filter-repo...\u0026#34; ## Read history with source username/email, replace with target COMMIT_CALLBACK=\u0026#34; if commit.author_email.decode(\u0026#39;utf-8\u0026#39;) == \u0026#39;$SRC_EMAIL\u0026#39;: commit.author_email = b\u0026#39;$TGT_EMAIL\u0026#39; \u0026#34; if [[ -n \u0026#34;$TGT_NAME\u0026#34; ]]; then COMMIT_CALLBACK+=\u0026#34; commit.author_name = b\u0026#39;$TGT_NAME\u0026#39; \u0026#34; fi COMMIT_CALLBACK+=\u0026#34; if commit.committer_email.decode(\u0026#39;utf-8\u0026#39;) == \u0026#39;$SRC_EMAIL\u0026#39;: commit.committer_email = b\u0026#39;$TGT_EMAIL\u0026#39; \u0026#34; if [[ -n \u0026#34;$TGT_NAME\u0026#34; ]]; then COMMIT_CALLBACK+=\u0026#34; commit.committer_name = b\u0026#39;$TGT_NAME\u0026#39; \u0026#34; fi echo \u0026#34;Generated callback:\u0026#34; echo \u0026#34;$COMMIT_CALLBACK\u0026#34; ## Rewrite history git filter-repo --force --commit-callback \u0026#34;$COMMIT_CALLBACK\u0026#34; echo \u0026#34;git filter-repo completed successfully\u0026#34; echo \u0026#34;Verifying first few commits after rewrite...\u0026#34; git log --all --pretty=format:\u0026#34;%h %ad %an \u0026lt;%ae\u0026gt;\u0026#34; --date=iso -5 echo \u0026#34;Removing backup refs...\u0026#34; ## Remove all refs with old data git for-each-ref --format=\u0026#39;%(refname)\u0026#39; refs/original | xargs -r git update-ref -d git for-each-ref --format=\u0026#39;%(refname)\u0026#39; refs/backup | xargs -r git update-ref -d echo \u0026#34;Expiring reflogs and pruning unreachable objects...\u0026#34; ## Expire old data locally before pushing git reflog expire --expire=now --all git gc --prune=now --aggressive echo \u0026#34;Verifying no lingering commits with source email anywhere...\u0026#34; ## Get list of commits with source email SOURCE_COMMITS=$(git for-each-ref --format=\u0026#39;%(refname)\u0026#39; | while read -r ref; do git log \u0026#34;$ref\u0026#34; --pretty=format:\u0026#34;%H%x09%ad%x09%an%x09%ae%x09%cN%x09%cE\u0026#34; --date=iso | awk -v src_email=\u0026#34;$SRC_EMAIL\u0026#34; \u0026#39; $4 == src_email || $6 == src_email { print FILENAME \u0026#34;\\t\u0026#34; $0 }\u0026#39; FILENAME=\u0026#34;$ref\u0026#34; done) ## If any commits remain with source email, exit if [[ -n \u0026#34;$SOURCE_COMMITS\u0026#34; ]]; then echo \u0026#34;\u0026#34; echo \u0026#34;[ERROR] Some commits still contain the source email \u0026lt;$SRC_EMAIL\u0026gt;:\u0026#34; echo \u0026#34;\u0026#34; echo -e \u0026#34;Ref\\tCommit\\tDate\\tAuthorName\\tAuthorEmail\\tCommitterName\\tCommitterEmail\u0026#34; echo \u0026#34;$SOURCE_COMMITS\u0026#34; echo \u0026#34;\u0026#34; echo \u0026#34;Aborting push.\u0026#34; exit 2 fi echo \u0026#34;Removing local refs under refs/merge-requests/ to avoid push errors...\u0026#34; ## Remove all refs under refs/merge-requests/ git for-each-ref --format=\u0026#39;%(refname)\u0026#39; refs/merge-requests | xargs -r -n 1 git update-ref -d || true echo \u0026#34;Adding remote origin after filter-repo cleanup...\u0026#34; ## Re-add origin (git-filter-repo removes it) git remote add origin \u0026#34;$REPO_URL\u0026#34; echo \u0026#34;Pushing all branches and tags to origin forcibly...\u0026#34; ## Push rewritten histories back up if [[ -n \u0026#34;${FORCE_PUSH:-}\u0026#34; ]]; then if ! git push origin --force --all; then echo \u0026#34;\u0026#34; echo \u0026#34;[ERROR] Failed to push rewritten commits to origin.\u0026#34; exit 1 fi if ! git push origin --force --tags; then echo \u0026#34;\u0026#34; echo \u0026#34;[ERROR] Failed to push rewritten commits to origin.\u0026#34; exit 1 fi else if ! git push origin --force --all; then echo \u0026#34;\u0026#34; echo \u0026#34;[ERROR] Failed to push rewritten commits to origin.\u0026#34; exit 1 fi if ! git push origin --force --tags; then echo \u0026#34;\u0026#34; echo \u0026#34;[ERROR] Failed to push rewritten commits to origin.\u0026#34; exit 1 fi fi echo \u0026#34;Successfully rewrote all commits and pushed to remote.\u0026#34; echo \u0026#34;Temporary repo location: $TMP_DIR/repo\u0026#34; exit 0 ","permalink":"/notes/git-rewrite-history/","summary":"Code snippet or command reference","title":"Git Rewrite History"},{"content":"Hugo is a static site generator that enables you to write your website\u0026rsquo;s content as Markdown files, and render them to nice-looking websites using Hugo themes. The word \u0026ldquo;static\u0026rdquo; in this context means that the files Hugo renders are meant to be served as-is, there is no \u0026ldquo;backend\u0026rdquo; for the site (unless you add custom Javascript code).\nStatic sites are perfect for many different kinds of websites, from personal blogs like this site to product sites (i.e. Brave browser\u0026rsquo;s website) to an organization\u0026rsquo;s home page (i.e. the LetsEncrypt project\u0026rsquo;s site) to documentation sites (i.e. DigitalOcean\u0026rsquo;s documentation). With a static site generator, you do not have to deal with web code like HTML or Javascript, but you still have the option of creating your own custom themes if that appeals to you.\nWhen Hugo renders your source code into a website, it outputs everything you need to start serving the site to a public/ directory, allowing you to host the page in Docker, or on a VPS you control, or with a service like Netlify. You could even host your site on a Raspberry Pi you own (although I would recommend looking into running the site in a Docker container, instead of a Canonical Snap). Anywhere that can serve HTML should be able to serve your compiled Hugo website.\nThis blog will run through the basic steps to initialize a Hugo project, host it on Github, and setup hosting with Netlify.\nRequirements Hugo You should also pick a Hugo theme, i.e. PaperMod Go (optional) You can add themes as a Hugo module instead of using Git submodules. Git Github account Netlify account Initialize Hugo repository There are different ways of structuring the repository, like putting the site\u0026rsquo;s content in a src/ directory or storing multiple Hugo sites in a single monorepo. This guide assumes you follow the default steps for initializing a Hugo project, where all source code is in the \u0026ldquo;root\u0026rdquo; of the repository.\nFirst, cd to a path where you want to initialize your Hugo site, i.e. ~/git or just ~/ and initialize the site with:\n1 hugo new site \u0026lt;your-blog-name\u0026gt; Replace \u0026lt;your-blog-name\u0026gt; above with the name of your blog. The documentation uses quickstart as an example. You can name the site whatever you want, and can change it later by editing the hugo.toml/hugo.yml file the hugo new site command creates. When you run the Hugo command, you will see output with some first steps and instructions for editing the hugo.toml to change the site\u0026rsquo;s configuration:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $\u0026gt; hugo new site your-site-name Congratulations! Your new Hugo site was created in /home/username/git/your-site-name. Just a few more steps... 1. Change the current directory to /home/username/git/your-site-name. 2. Create or install a theme: - Create a new theme with the command \u0026#34;hugo new theme \u0026lt;THEMENAME\u0026gt;\u0026#34; - Or, install a theme from https://themes.gohugo.io/ 3. Edit hugo.toml, setting the \u0026#34;theme\u0026#34; property to the theme name. 4. Create new content with the command \u0026#34;hugo new content \u0026lt;SECTIONNAME\u0026gt;/\u0026lt;FILENAME\u0026gt;.\u0026lt;FORMAT\u0026gt;\u0026#34;. 5. Start the embedded web server with the command \u0026#34;hugo server --buildDrafts\u0026#34;. See documentation at https://gohugo.io/. This is an example of the files hugo new site creates:\n1 2 3 4 5 6 7 8 9 10 11 your-site-name/ ├── themes ├── static ├── layouts ├── i18n ├── hugo.toml ├── data ├── content ├── assets └── archetypes └── default.md Since this blog post is not meant to be an in-depth tutorial of how to build a website with Hugo, we will not go into detail on every single file and folder this command creates, but some important things to know are:\nThe themes/ directory is where you store themes added as Git submodules. This guide assumes you are using Hugo modules to install themes, instead of the old Git submodule way, so you will not need to interact with the themes/ directory. This is also where you would store a custom theme, if you wanted to create your own Hugo theme for your site. The static/ directory is where you would store static assets for your site, including images and a favicon.ico for the site. Although counter-intuitive, it is generally considered best practice to include the binaries for your static resources in the Git repository. Save your favicon.ico to static/favicon.ico (if you have one), and any images you use in your posts in static/img (this directory doesn\u0026rsquo;t exist by default, you have to create it). The layouts/ directory is where you can create Hugo templates for finer-grained control of how content is displayed on your site. The hugo.toml file is the main configuration file for your site. If you prefer YAML, you can copy the contents of hugo.toml into convertsimple.com, delete or rename hugo.toml to hugo.yaml/hugo.yml, and paste the converted configuration into the YAML file. Hugo will detect a file named hugo.yml or hugo.toml wherever you run hugo commands from. This guide assumes you are using YAML for your configuration.\nTip You can initialize Hugo with a YAML config file instead of TOML by running the hugo new site command with a --format yaml flag:\n1 hugo new site site-name --format yaml Convert Site to Hugo Module Next, initialize your site as a Hugo module:\n1 hugo mod init github.com/username/your-site-name After running this, you will see a go.mod file. This allows Go to mannage your site so you can install themes like they are Go packages. For example, to install the PaperMod theme by editing your hugo.yml file and adding the following block of code:\n1 2 3 module: imports: - path: github.com/adityatelange/hugo-PaperMod The full hugo.yml should now look like this:\n1 2 3 4 5 6 7 baseURL: \u0026#39;https://example.org/\u0026#39; languageCode: en-us title: My New Hugo Site module: imports: - path: github.com/adityatelange/hugo-PaperMod Note Run hugo mod tidy; this will download the theme(s)/module(s) you declare, remove old versions, and update your go.mod file for you.\nRun Local Hugo Development Server Now you can test running the server with hugo serve, which will compile the site and start serving it at http://localhost:1313/ by default. To change the host address, i.e. to access it from another machine on the network, use --bind 0.0.0.0, and to change the port use -p \u0026lt;port\u0026gt;. This is Hugo\u0026rsquo;s development server. As you edit files, the server will \u0026lsquo;hot reload,\u0026rsquo; re-compiling the Markdown you write and restarting the server.\nHugo does this very quickly. Each page takes mere milliseconds to render, even those with images or a lot of text, and it caches between rebuilds, only re-rendering the new content. This leads to a pleasant \u0026ldquo;change something and see it instantly\u0026rdquo; writing experience.\nIf you start the server with -D, Hugo will also render your drafts in the development server.\nGit Repository Setup Run git init -b main in your Hugo site\u0026rsquo;s directory to initialize a new Git repository. Before committing any code, add a .gitignore that ignores Hugo\u0026rsquo;s public/ and resources/ directories that it creates when you run the development server:\n1 2 /public/ /resources/ Then, do your initial commit:\n1 2 git add * git commit -m \u0026#34;Initial commit\u0026#34; Create a repository on Github for your Hugo site. This should match the URL you used when you create the Hugo module with hugo mod init github.com/user/your-site-name. Add the remote to your local repository:\n1 git remote set origin git@github.com:username/your-site-name.git And push your code with git push -u origin main.\nNetlify Setup Create an account on Netlify. During the setup process, you will be asked to create a project. Allow Netlify to integrate with your Github and find the repository with your Hugo site. If you already have a Netlify site, add a new project and use \u0026ldquo;Import an existing project\u0026rdquo; and find your Hugo repository.\nWhen you are prompted to input build settings like the base directory/package directory, the build command, the publish directory, etc, leave all of these blank. You can optionally leave the functions directory as netlify/functions. If you set values here, the site will fail to build because the hugo.yml interferes with these settings. Do your site configuration in hugo.yml, not Netlify.\nWhen asked which branch to trigger Netlify deploys on, you have a decision to make: do you want your site to deploy every single time you open a PR to the main branch, including times where you are doing small cleanup chores like updating the .gitignore, or setting up a new pipeline? Or do you want to have more control over deploys, i.e. only when merging into a branch named netlify?\nI chose the latter, and created a netlify branch, then configured Netlify to deploy from the netlify branch instead of main. I also turned off deployment previews, so any PR into netlify will trigger a deploy.\nnetlify.toml Config After finishing the setup, create a netlify.toml file in the root of your Hugo repository :\nNote If you have a domain name, you can set it in HUGO_BASEURL instead of the Netlify app URL. Also, make sure to check the current versions of Hugo and Node. for NPM versions, just put the major version number, i.e. 24, 25, etc.\n1 2 3 4 5 6 7 8 9 10 [build.environment] HUGO_BASEURL = \u0026#34;https://netlify-project-name.netlify.app\u0026#34; HUGO_VERSION = \u0026#34;0.152.2\u0026#34; HUGO_ENV = \u0026#34;production\u0026#34; NODE_VERSION = \u0026#34;24\u0026#34; HUGO_ENABLEGITINFO = \u0026#34;true\u0026#34; [build] command = \u0026#34;hugo --gc --minify\u0026#34; publish = \u0026#34;public\u0026#34; This file tells Netlify how to build your Hugo site. Commit it to git with git add * \u0026amp;\u0026amp; git commit -m \u0026quot;Add netlify config\u0026quot; and push to main with git push. Even better, create a new branch before adding/committing your code, i.e. git switch -c feat/netlify-setup, then add the files and commit message. When you push, use git push -u origin feat/netlify-setup. Merge the branch into the main branch in Github.\nExample Production Hugo Dockerfile If you plan to host your Hugo site yourself, you can set up a Dockerfile to build your site and serve it behind a proxy like Caddy or NGINX.\nYou can use a multi-stage Docker build to have a smaller final image that only has your rendered HTML and the Caddy server to serve it:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 FROM hugomods/hugo:exts AS builder WORKDIR /src ## Copy Hugo site files \u0026amp; config COPY archetypes/ archetypes/ COPY assets/ assets/ COPY content/ content/ COPY layouts/ layouts/ COPY static/ static/ COPY hugo.yml go.mod go.sum .hugo_build.lock ./ ARG HUGO_BASEURL=http://localhost/ RUN hugo \\ --minify \\ --gc \\ --baseURL=\u0026#34;${HUGO_BASEURL}\u0026#34; \\ --destination /output/public FROM caddy:alpine COPY --from=builder /output/public /usr/share/caddy COPY ./Caddyfile /etc/caddy/Caddyfile EXPOSE 80 443 CMD [\u0026#34;caddy\u0026#34;, \u0026#34;run\u0026#34;, \u0026#34;--config\u0026#34;, \u0026#34;/etc/caddy/Caddyfile\u0026#34;] Create a Caddyfile the container will use to configure the Caddy server:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { # debug email {$CADDY_EMAIL} } {$CADDY_SITE_ADDRESS} { root * /usr/share/caddy file_server encode gzip header { X-Content-Type-Options nosniff X-Frame-Options DENY } } When you build the container, you will need to set environment variables for CADDY_EMAIL (for automated LetsEncrypt SSL certificates) and CADDY_SITE_ADDRESS (for handling connections coming to the Caddy URL). The 2 env vars are \u0026ldquo;build arguments\u0026rdquo;, which you can inject in your docker build command using --build-arg ARG_NAME=value. For example, to build this Dockerfile:\n1 docker build --build-arg CADDY_EMAIL=\u0026#34;youremail@address.com\u0026#34; --build-arg CADDY_SITE_ADDRESS=\u0026#34;myblog.com\u0026#34; . Check Links with Lychee Lychee is a fast link checker you can use to check your site for broken links. You can install it locally and point it at your live site, or you can run it against the public/ directory after Hugo builds to check your links before deploying.\nLychee\u0026rsquo;s syntax is simple:\n1 2 3 4 5 ## Check links on your live site lychee https://your-site-name.com ## Check links in the public/ directory after a Hugo build lychee public/ As a Github action:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 --- name: \u0026#34;Test Blog Links\u0026#34; on: ## Allow manual runs workflow_dispatch: ## Every 6 hours schedule: - cron: \u0026#34;0 */6 * * *\u0026#34; permissions: contents: read jobs: test: runs-on: ubuntu-latest concurrency: group: ${{ github.workflow }}-${{ github.ref }} steps: - name: Test live site links uses: lycheeverse/lychee-action@v1 with: args: \u0026gt;- --base-verification false --no-progress \u0026#34;${{ vars.HUGO_BASEURL }}\u0026#34;/* Or in a full pipeline that builds the site and then tests the public/ directory (this is useful to do before deploying):\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 --- name: \u0026#34;PR checks\u0026#34; on: pull_request: branches: [netlify] types: [opened, synchronize, reopened] permissions: contents: read pull-requests: write env: HUGO_VERSION: \u0026#34;0.154.3\u0026#34; jobs: test: name: Build \u0026amp; test Hugo site runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Vale prose linting uses: errata-ai/vale-action@v2.1.1 with: files: \u0026#39;[\u0026#34;content/**.{md}\u0026#34;]\u0026#39; - name: Setup Hugo uses: peaceiris/actions-hugo@v3 with: hugo-version: ${{ env.HUGO_VERSION }} extended: true - name: Build site run: hugo --gc --minify - name: Check links with Lychee uses: lycheeverse/lychee-action@v1 with: args: \u0026#34;public/**/*.html --base .\u0026#34; fail: false Closing Building a blog with Hugo is fun, and relatively quick and easy to get up and running. You can build a complex site for your product, or a simple blog you update a couple times a year, all with the same tooling. The minimal amount of configuration you need to get started lends itself to quick development, while still allowing you to build up a more complex configuration over time. Hugo modules make it even easier to install themes and site extensions, and the ability to host the site anywhere that can serve static web files gives you a ton of flexibility to where you put the site online.\nThis guide did not go in-depth on configuring and customizing Hugo, nor did it walk through creating content for the site. The Hugo quickstart guide will do a better job of walking you through building your first blog, and I highly recommend keeping this site open and using the search bar at the top as you\u0026rsquo;re learning to find new ways of using Hugo.\n","permalink":"/posts/host-with-netlify/","summary":"\u003cp\u003e\u003ca href=\"https://gohugo.com\"\u003eHugo\u003c/a\u003e is a \u003ca href=\"https://www.cloudflare.com/learning/performance/static-site-generator/\"\u003estatic site generator\u003c/a\u003e that enables you to write your website\u0026rsquo;s content as Markdown files, and render them to nice-looking websites using \u003ca href=\"https://themes.gohugo.io\"\u003eHugo themes\u003c/a\u003e. The word \u0026ldquo;static\u0026rdquo; in this context means that the files Hugo renders are meant to be served as-is, there is no \u0026ldquo;backend\u0026rdquo; for the site (unless you add custom Javascript code).\u003c/p\u003e\n\u003cp\u003eStatic sites are perfect for many different kinds of websites, from personal blogs like this site to product sites (i.e. \u003ca href=\"https://brave.com\"\u003eBrave browser\u0026rsquo;s website\u003c/a\u003e) to an organization\u0026rsquo;s home page (i.e. \u003ca href=\"https://letsencrypt.org\"\u003ethe LetsEncrypt project\u0026rsquo;s site\u003c/a\u003e) to documentation sites (i.e. \u003ca href=\"https://docs.digitalocean.com\"\u003eDigitalOcean\u0026rsquo;s documentation\u003c/a\u003e). With a static site generator, you do not have to deal with web code like HTML or Javascript, but you still have the option of \u003ca href=\"https://gohugo.io/commands/hugo_new_theme/\"\u003ecreating your own custom themes\u003c/a\u003e if that appeals to you.\u003c/p\u003e","title":"Host Hugo with Netlify"},{"content":"\nI have been very interested in Linux since my entry into the Wonderful World of Unix in 2006. I found Ubuntu and installed it on a crappy Dell desktop computer I was given when I was doing online schooling. The computer originally came with Windows, and one day while I was browsing, I decided to search for “alternative to Windows.” Linux popped up right away. I had never heard of Linux before, but after voraciously reading article after article, I decided Linux was the path for my future.\nLike most Linux newbies, Ubuntu was the first distribution I found. Back in 2006, Ubuntu wasn’t ugly, necessarily, but it definitely was not a pretty distribution. When I first found Ubuntu, they were on version 6.10, “Edgy Eft.” My initial thoughts were that their naming convention was stupid (Ubuntu picks an animal for each release and creates an adjective-noun alliteration for it), but it looked interesting, and I decided to try it. I downloaded the image and burnt it to a CD (I’ve since moved to multibooting Linux images from a USB stick), popped it in my CD tray, and jumped straight down the rabbit hole.\nI was always interested in computers as a kid, but I had never entered into the world of partition and driver hell that was such a reality back in 2006. When I booted the disk, I was presented with an installer (back then, I believe it was still a command line installer of sorts…it was graphical, but not like today’s installers; there was a lot of room for error). Before I started the installation, I had printed out a “how to install Ubuntu” guide, but I didn’t read very much of it previous to my first attempt. At that point, I had not had much trouble in figuring out how to install, configure, and use software in the Windows world.\nDuring my first attempt, I completely wiped out my Windows partition and deleted everything I had on my computer, because I had no idea what “format” meant, or what a partition even was. I also did not successfully install Linux on my first attempt. I thought I had destroyed my computer.\nI had no idea what I did wrong, but I read through the guide I printed, and realized retroactively that I should have done that initially. I learned about partitions, and realized with my skillset, there was no way to get the data I had lost back. I accepted that truth, and realized I had no hope of restoring a Windows install, because I had not been sent a recovery disk with the computer. I wiped the tears off my face and resolved to get Linux installed on my computer, no matter how difficult the task was.\nI put the Ubuntu CD I had made back in the tray, cracked my knuckles like I saw people on TV do, and braced for impact. The installation window came back up, and this time, armed with my handy installation guide, managed to install Ubuntu. I didn’t do any fancy partitioning or anything, seems how I had just learned partitioning was even a thing, so Ubuntu was on my entire disk. I booted up my computer, and all was right in the world.\nActually, that’s not the case at all. Back in 2006, Linux was awesome, but had a ton of driver problems. My computer was a low end Dell with an Ethernet driver that was not included in the installation image. After many hours of developing my troubleshooting skills without an Internet connection, I used my brother’s working Windows computer (and I’d be lying if I said I wasn’t envious of his working computer, or that he had not decided to be adventurous with me) to research installing the driver for Ubuntu. I found some documentation for the model of Dell I was using, and found that there were a few other driver problems as well. I downloaded the drivers, put them on a USB stick, and printed the guide for installing the drivers on Ubuntu (what a year 2006 was, where we didn’t have smartphones to just pull up the guides when we needed them. So much wasted paper…).\nLet’s recap. At this point, I had a CD burnt with the Ubuntu live image on it, a USB stick with a few drivers, and about 25 pages worth of documentation. I had failed the installation initially, destroyed my Windows partition, lost some data that was pretty important at the time (I had to redo a few assignments), and scared myself a little into thinking Linux might not be for me. Luckily, I am a bit (ok, very) defiant when it comes to computers besting me, and I was not about to let this low end Dell tell me who’s boss. This stupid computer was going to run Linux whether it liked it or not, and based on my experience trying to install Linux and getting the proper drivers, it did not want to run Ubuntu. Tough luck, you stupid Optiplex.\nBack to the story! I popped the USB stick into the computer to install the drivers I had found online…only to find that in 2006, Ubuntu did not support FAT formatted drives out of the box. I punched myself in the face in frustration, and went back to my brother’s computer to learn about different file systems. I didn’t understand any of what I was reading, but it boiled down to me learning what the “Format” option does when right clicking a drive on Windows. I formatted the drive, and in my young stupidity, forgot to back up the drivers. After curling up in a ball and rolling around the floor in frustration, on the verge of giving up and accepting that my computer was currently as useful as an uncomfortable footrest, I decided this was all a learning process, and shifting my mentality from “why in the world do so many people love Linux” to “there must be a reason so many people love Linux,” I got back on my brother’s computer, downloaded the drivers again, and took my newly formatted EXT3 drive back to my room. I sat at the computer, prayed to any gods who were listening, and put the USB stick into the slot on my computer.\nA wave of relief rushed over me as I saw a neat little animation pop up on my desktop, confirming that my disk had properly mounted (of course, I didn’t know what “mounting” a disk was, but thankfully I didn’t have to play around in my FSTAB file, or else I would probably not be enjoying Linux today, stuck instead with Windows in Microsoft Land). I opened the drive, found my drivers, and moved them to my Downloads folder in Ubuntu. I double clicked them to install, just like you would on Windows, right?\nOf course it wouldn’t be that simple! What was I even thinking? Linux is a complex beast, and until you know what you’re doing and see the efficiency, Linux’s simplicity feels like complexity. Through my tears of frustration, I consulted the guide I had printed for installing the drivers on Ubuntu. Some thoughts running through my head while reading were “Shell? Terminal? Why do I have to type something in this weird program, just to install a driver?! What have I gotten myself into…” I decided not to think, and just to follow the guide. I found the Terminal program, opened it up, and noticed it looked a lot like the command prompt on Windows. I previously used the command prompt for the ping command exclusively, because on my Windstream connection (little plug: Windstream sucks. Don’t use.), pinging could help me determine if my computer was the problem, or if our connection was out, and based on past experiences with Windstream, it was usually the latter.\nAnyway, with the terminal opened, the guide told me to “cd” to the Downloads folder, and use a command called “chmod +x” to tell the computer the driver files were executable. “Hold up…the computer doesn’t just know I want to run something? I have to actually tell it this file can be executed? Good lord, I hate Linux.” I think that is a more common thought for newbies than people realize/remember. It’s honestly a miracle and a testament to how great Linux is that any converters actually stay with it.\nGetting Back on the Internet, and Next Steps So, my computer was finally ready to install the drivers. I had to type some weird command with a dot in front of it (./[driver name]) to get the driver to install. I later learned that the “./” portion of the command tells the computer to run whatever input you’re giving it through the shell.\nA bunch of text started flying through the terminal, and I thought for sure I had either broken my computer again, or that I had learned how to hack, since this is how it looked in the movies. Of course, neither of those scenarios were my reality, but I let the commands run with faith that my computer would not catch fire in front of my eyes.\nWhen the command finally stopped running and the terminal was dutifully waiting for more commands, my network connection icon in the statusbar started spinning, and I got a little popup that my network connection was active. I sat stunned for a minute that I’d Forrest Gump’d my way into a working Linux installation. I opened an Internet browser (I had been using Firefox on Windows, so this was one thing I was familiar with in this new land), went to Google, and danced with joy that I had a working Internet connection.\nI could go into more detail about the troubles I had learning Linux, but suffice it to say, learning Linux coming from never even hearing about it before was a long, arduous process of trial and extremely frustrating error. The thought of adjusting to the command line alone was daunting enough to keep me wondering if I had made a huge mistake, but after a couple days of using Ubuntu, I was captivated by the uniqueness and simultaneous familiarity. I love change, and I love experimenting with computers, and this was a whole new world for me. I got comfortable with the command line, and found it to be far superior to the click and drag interface I was used to, although people new to Linux will be happy to hear you can do pretty much everything graphically, especially on Ubuntu. No scary terminal window necessary!\nMy Time with Linux Since Then It didn’t take me very long to learn that Linux was just the core of the operating system, and there were loads of different “flavors,” or “distributions,” of Linux. I had started on Ubuntu, which I learned was an offshoot of the great Debian distribution, plus a few enhancements. Once I traced Ubuntu’s roots back to Debian, I found that there were many, many different offshoots of Debian, and even some that came from Ubuntu. The more I searched, the more I found that Linux is one hell of an ecosystem, with distributions for just about anything you can want. There are distributions crafted to be run solely as a server; distributions that value security and stability over cutting edge technology; distributions like Arch Linux that are always up to date, to the point of being unstable at times; and distributions with a nice mix of stability and freshness, like Fedora.\nI decided to try Fedora out, and found that Gnome 3 looked much nicer than Ubuntu’s desktop, which was based on the older Gnome 2. Ubuntu eventually created their own entirely unique environment, called Unity, and caused quite a divide in the community: some people loved the fresh, unique take on the desktop, and some people despised the fact that Ubuntu tried to pigeon hole them into change. One thing I’ve learned about the Linux community is that people really, really value their freedom of choice, and when a distribution makes an executive decision, they will hear loud and clear the displeasure of the people they pissed off.\nSince trying Ubuntu and Fedora out, I have become what the community dubbed a “distro hopper.” Distro hoppers want to get their feet wet with as many distributions as they can find. I’ve tried ’em all, starting with Ubuntu, moving to Fedora, and from there travelling swiftly through Linux Mint, to Debian when I felt I had learned enough about Linux to try the mother distribution; I tried ZorinOS because it promised to be familiar to people coming from Windows, and they were right; I installed openSUSE because I liked the green lizard, but I eventually made my way back to Debian-land and settled on Linux Mint. It felt fresh and clean, but still offered the simplicity and idiot-proof nature of Ubuntu. I stuck with Mint for a long time, but eventually the itch hit me again, and I started hopping around.\nWhen I got bored of the safeness of Linux Mint, I branched out into fringe and new distributions. I found elementaryOS, which was, at the time, a very new branch from Ubuntu. Its main offering was a positively gorgeous interface, taking obvious inspiration from Apple’s Mac OS X (they deny that they were inspired by Apple’s OS, but it’s pretty apparent to anyone that they at least valued OS X’s clean, consistent interface and color scheme). I bounced from elementaryOS over to DeepinOS, which is a Chinese distribution that’s doing a lot of new and exciting things to the Ubuntu desktop world.\nEventually I got tired of hopping around and not finding the one distribution that fit all my needs. I wanted something that was stable, had up to date packages so I could try new things, was easy to manage, and was modifiable in a way I had not found yet. I am a compulsive modder, changing things just for the sake of change. I had heard of Arch Linux, a distribution that the community likes to tout as “for hardcore users only.” There is surely a steep learning curve, but the documentation and community is so passionate about their distribution that the entire process of installing and maintaining an Arch Linux install is very demystified at this point. I had read about how customizable Arch Linux is, and decided this was surely the next step in my journey.\nI downloaded the net install (a very small image that requires you to be connected to the Internet, where it then downloads the rest of the installation) and set to work. And good lord did I mess a lot of things up. Setting Arch Linux up is to this day one of the hardest, most time consuming, and frustrating things I have ever done. It took me weeks to get a working installation. After failing the first 5 times, I decided to try in a virtual machine. I would go through the Arch installer, get a system up and running, delete the virtual machine, and try again. I probably did this 20 times before I felt comfortable trying again on my computer.\nAnd I still failed. I’ve only ever had 1 working Arch install, and at that point I was too scared to mess with it and screw things up even more. I read around, and a lot of people suggested Antergos, which is essentially a simple, graphical installer for Arch Linux that adds a repository for themes.\nThe Cnchi graphical installer for Antergos was beautiful, and worked like a charm. I had a working Arch Linux install on my computer within 15 minutes. And as it turns out, Arch Linux is the absolute perfect distribution for me. It’s stable, it’s faaaast, it’s customizable beyond belief, and most importantly for me, it’s as up to date as you can be. As soon as a program is updated, it gets added to Arch Linux’s central repository, or its indescribably useful User Repository (AUR), and you can download it with a simple pacman -S command. I could write a whole post about my journey with Antergos, and perhaps I will…but for now, suffice it to say I had found my home.\nWith Arch Linux, I had everything I wanted. No more distro hopping to get a package update I wanted, or a theme that only worked with a certain base (Debian or Redhat, for instance). No more obliterating my installation with a simple update, which happened far too frequently in my time with openSUSE. Antergos was it for me. I grew to love how configurable it was, which initially was a very daunting task (pretty much everything is configured through the command line by editing text files).\nWhere I Am Now with Linux This post is getting long, as I forgot how passionate I am about Linux until I took this nostalgic trip down Linux-Hell memory lane.\nSince I first accidentally wiped out my Windows installation, I have been treading deeper and deeper into the Linux forest. I have learned more about computers and their inner workings in my ~10 years with Linux than I ever knew with a Windows installation. I have lost data, formatted my hard drive every which way hundreds of times, found new uses for Linux in my everyday life, and set up my own home server using an ESXi host running a few Linux virtual machines (another post for another time).\nI took the plunge and decided to use Linux as my main operating system a couple years ago, booting into Windows infrequently to use a couple of programs I simply can’t use to their full potential in Linux (namely FL Studio and a couple of Windows only games). It’s true that at first, Linux is scary. You have to learn the command line if you want to do anything besides basic Internet browsing and word processing; you have to read a lot of documentation and forum posts to learn how you stupidly messed up your installation, and you have to develop a tough skin to deal with the community, which is made up of some really grumpy old timers and a disproportionately large group of newcomers who ask a lot of questions. It’s a testament to Linux’s greatness that so many people put up with all of that, just to use the operating system on their computer.\nBecause of my love for Linux, I am now working towards learning what I need to learn to become a Linux Systems Administrator, where I will get to work with Linux computers and servers for a living, and learn even more about this wonderful software project that has broken so much ground over the years. I’m sure there will be many more long nights spent figuring out where I went wrong in an update, or why my configuration files aren’t working as intended, but I look forward to being tired the next day from staying up too late playing on my computer, feeling satisfied that I fixed whatever went wrong, and that I am still loving the Linux Life.\n","permalink":"/posts/linux-frustrated-loving/","summary":"\u003cp\u003e\u003cimg alt=\"Header image\" loading=\"lazy\" src=\"/posts/linux-frustrated-loving/linux-story.jpeg#center\"\u003e\u003c/p\u003e\n\u003cp\u003eI have been very interested in Linux since my entry into the Wonderful World of Unix in 2006. I found Ubuntu and installed it on a crappy Dell desktop computer I was given when I was doing online schooling. The computer originally came with Windows, and one day while I was browsing, I decided to search for “alternative to Windows.” Linux popped up right away. I had never heard of Linux before, but after voraciously reading article after article, I decided Linux was the path for my future.\u003c/p\u003e","title":"How Linux Frustrated Me Into Loving It"}]