6 months, 1 line of code

That was a ... journey

So it's been somewhat longer than I'd hoped. After the first blog post in this series I started on the second item in the checklist, "Automate the build". And that's where I got suited up and blasted off as a fully fledged architecture astronaut.

Constructing the monolith

Everything started off reasonably sensibly. Since I'm using Clojure's deps system to configure my application it made sense to use the tools.build library to build an UberJAR for the service. Because of the simplicity of the application, so far, it was basically a copy-&-paste job of the tools.build example.

The first step was to create a :build alias in my deps.edn file to manage the build.

{...
 :aliases {...
           :build {:paths ["infra"]
                   :deps {io.github.clojure/tools.build {:mvn/version "0.9.6"}}
                   :ns-default build}}}

I like to keep all my infrastructure/configuration away from the rest of my code, so I created an infra directory and I created a build.clj file to house the build code.

restaurant
...
├── infra
│   └── build.clj
...

I then copied the example build namespace from the guide. Including the build number in the file name of the JAR makes life a little more complicated later, so I removed it and tided up the rest of the file.

(ns build
    (:require [clojure.tools.build.api :as b]))

(def target-dir "target")
(def class-dir (format "%s/classes" target-dir))

;; delay to defer side effects (artifact downloads)
(def basis (delay (b/create-basis {:project "deps.edn"})))

(defn clean [_]
  (b/delete {:path target-dir}))

(defn uber [_]
  (clean nil)
  (b/copy-dir {:src-dirs ["src"]
               :target-dir class-dir})
  (b/compile-clj {:basis @basis
                  :class-dir class-dir})
  (b/uber {:class-dir class-dir
           :uber-file (format "%s/restaurant.jar" target-dir)
           :basis @basis
           :main 'restaurant}))

This allowed me to create the UberJAR from either the REPL (as long as it's started with the build alias)

(build/uber nil)

or the command line.

clojure -X:build uber

Both of those commands creates the UberJAR at target/restaurant.jar.

I was now able to run the application UberJAR from the command line

sudo java -jar target/restaurant.jar

and then check that it worked using curl (or your web browser of choice)

$ curl localhost
Hello World!

Shooting for the stars

I had recently come across Large Scale Software Development (and a big trap) by the YouTube channel Code to the Moon that nicely encapsulates the architecture I wanted to use. Essentially, I wanted to architect the service as a stateless monolith that is run behind a load balancer. This allows for fairly straight forward scaling (both horizontally and vertically) and downtimeless deployments, two things that modern software engineering embraces. In my experience this is a suitable architecture for the vast majority of projects to begin with, and for a lot will be all the architecture that will ever be needed.

Wishlist

"All" I wanted was somewhere I could deploy my service that

Platform-as-a-service

Because I'm a developer most of the time my ops skills are a gaping chasm of desirability. Could I learn to cobble together something in AWS, GCP or Azure, probably? Could I do something similar in kubernetes (or k8s or whatever it's called), it's probably not beyond me? Did I want to spend a bunch of time doing that? Nope. Would it have been secure, performant or reliable? Highly unlikely. All I wanted was someone to do the hard work of putting all the pieces together and present them on a platter to me. Not too much to ask, I think you'll agree.

API/CLI first

My intent is to deploy this service in small increments and very often using some sort of CI/CD tooling, so at the very least the deployments must be automatable. If everything else is as well, that will allow me to build any tooling I might need. I don't want your easy "one button deployment" I want a simple "one call deployment".

Run a javaagent

Logging has been a wonderful asset over the last few decades, but I really believe that tracing (via OpenTelemetry) and the observability it provides is the future of understanding live systems. I suspect it will supersede most, if not all, use cases for logging (not with the certainty that certain corners of the internet believed Blockchain was the future finance or similar corners that A.I. is the future of everything, but pretty close). OpenTelemetry provides a Java agent that can automatically instrument a JVM application using libraries that it knows about. E.g. it will instrument the Jetty HTTP server because that's widely known enough that the good folk over at the OpenTelemetry project have created automatic instrumentation for it as opposed to the HTTPKit server, which isn't as well known. For me to benefit from this automated tracing I need to be able to start my application with this Java agent.

Won't let me deploy trashed service versions

I'm a firm believer in trunk based development, or at the very least, short lived feature branches. Ideally I was looking for a service that would provide canary releases which would allow me to progressively roll out new versions of my service to increasingly larger percentages of my users. At the very least I wanted somewhere that would not allow me to deploy obviously broken versions of my service.

Cheap

I'm only a lowly software engineer, this isn't an attempt to make money and I already have plenty of expensive hobbies. I don't need to add cloud computing to that list.

Google-fu failures

I'm not proud to say it, but in this moment, my Google-fu failed me. Heroku looked promising as it could host the UberJAR, but there was no way that I could find to run the OpenTelemetry javaagent alongside it. Railway probably should have been enough, but the documentation didn't immediately click and somehow I missed fly.io (we'll get back to that one).

Blasting off

So I did what any self-respecting (some might say foolish or naive) software engineer suffering from "not invented here" syndrome would do and spent the next 6 months, off and on, building my deployment process through SSH to a Digital Ocean droplet. Had I not been suffering from said syndrome I could have spent that time learning something useful, like k8s or AWS. Don't get me wrong, I learnt a lot about Docker's API, how to use lispyclouds contajners library and Metosin's sieppari, and programmatically using SSH, but it appears building a PaaS isn't as easy as it first looks. Who knew?

Crashing back to earth

And then fly.io turned up in one of my social media feeds (I suspect it was The Primeagen, but I can't find the video now). It's a PaaS primarily driven through the CLI, it runs Docker containers so I can run a javaagent alongside my service, it's got multiple deployment options and it's cheap. Plus it handles SSL certs, secret management and has GitHub Actions integrations. Tick, tick, tick, tick. Okay, it's got a release option called "Canary" that isn't what I would describe as a canary release, but at least it should be enough to stop me shooting myself in the foot. If I'm reasonably careful.

Containing the UberJAR

Since I'm not a Docker expert I decided to steal the expertise of other humans who actually know what they're doing. Practically has some excellent documentation on building Docker images for Clojure applications. Andrey Fadeev has a really useful YouTube video about Docker and Clojure that helped it click for me. In fact, all his videos are great, if you're looking to step up your Clojure game you could do a lot worse than work your way through his back catalogue.

I created a following Dockerfile in the infra directory.

restaurant
...
├── infra
│   └── ...
│   └── Dockerfile
...

The strategy was to use a fully featured base to build the UberJAR and then a very thin base to run service itself.

FROM clojure:temurin-21-alpine AS builder

RUN mkdir -p /build
WORKDIR /build

COPY deps.edn /build/
RUN clojure -P -X:build

COPY ./src /build/src
RUN mkdir -p /build/infra
COPY ./infra/build.clj /build/infra/
RUN clojure -T:build uber

FROM eclipse-temurin:21-jre-alpine AS final

LABEL org.opencontainers.image.source=https://github.com/HughPowell/restaurant
LABEL org.opencontainers.image.description="Restaurant reservation application"
LABEL org.opencontainers.image.licenses="MPL-2.0"

RUN apk add --no-cache \
    dumb-init~=1.2.5

ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    clojure

RUN mkdir -p /service && chown -R clojure. /service

USER clojure

WORKDIR /service
COPY --from=builder --chown=clojure:clojure /build/target/restaurant.jar /service/restaurant.jar

ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["java", "-jar", "/service/restaurant.jar"]

Since I was defining a license here I also included the text of the license in a LICENSE file at the root of the project. My go to is the Mozilla Public License V2 (MPL-2.0).

Flying back to the moon

Now this is what I call a quick start guide. Install, signup, launch (which generated a local config file in infra/fly.toml), deploy, all from the CLI. Just one small problem. It wouldn't let me (probably quite reasonably) expose port 80 on the docker container, so I decided to update the application to listen on port 3000 and then exposed that port from the Dockerfile. I didn't need to change the port the service ran on, but it meant the added benefit that I could run the UberJAR locally without needing sudo. Once that was fixed up I successfully deployed the service and for good measure I pointed a subdomain of my hughpowell.net domain name to it.

Now all I needed was a way to prevent me shooting myself in the foot as often as possible. Fly.io provides a number of deployment strategies including what they call 'canary'. This boots a single additional machine, waits for it to become healthy and then replaces all the running machines one-by-one. Not how I would describe a canary deployment strategy, but enough for the time being.

To enable this I set up a [deploy] section in my infra/fly.toml file like so

[deploy]
  strategy = 'canary'
  max_unavailable = 1
  wait_timeout = "1m"

With my deployments now configured all I needed was to automate them every time a change was committed to main. I chose GitHub Actions as my CI/CD pipeline orchestrator of choice. No great reason, it's integrated into GitHub, I've used it a couple of times before and it seems as competent as anything else at this scale. Having added my fly.io API token to the FLY_API_TOKEN secret I check out the project, install the flyctl application and deploy the service. I limit the job to 15 minutes because I never want build times to exceed that.

name: Build the Restaurant service
on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup deployment controller
        uses: superfly/flyctl-actions/setup-flyctl@master

      - name: Deploy service
        run: flyctl deploy --config infra/fly.toml
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

So, yeah. The last six months has basically boiled down to flyctl deploy --config infra/fly.toml and a lot of learning.

What's going on?

Now that my service is running in production I need a way to check that it's doing everything it should do and nothing it shouldn't. As noted above I wanted to use OpenTelemetry for that. Instrumenting the service locally required downloading the OpenTelemetry Java agent and then starting the UberJAR with the -javaagent:<path-to-OpenTelemetry-agent> flag. You can also pass a similar option to your REPL and you'll get traces while developing. There's a couple of options for interacting with OpenTelemetry locally. You can export the traces to the console or to an observability tool like SigNoz, my current tool of choice, or Digma.

What about the old school logs?

Unfortunately OpenTelemetry hasn't penetrated the entire Java ecosystem (yet!) so I still needed a way to output logs that are being written by the libraries I have and will import. Luckily the OpenTelemetry defines a standard which the Java agent has implemented to facilitate recording logs as spans.

First off I had to deal with the chaos that is Clojure logging infrastructure. To be fair it's mostly Java's fault. Either way, the magic incantations were to add a stack of dependencies

{...
 :deps    {...
           ch.qos.logback/logback-classic {:mvn/version "1.2.3"}
           org.slf4j/jcl-over-slf4j       {:mvn/version "1.7.30"}
           org.slf4j/jul-to-slf4j         {:mvn/version "1.7.30"}
           org.slf4j/log4j-over-slf4j     {:mvn/version "1.7.30"}
           org.slf4j/osgi-over-slf4j      {:mvn/version "1.7.30"}
           org.slf4j/slf4j-api            {:mvn/version "1.7.30"}
           ...                                                                                        
           }
 ...
}

the OpenTelemetry logback appender

{...
 :deps    {...
           io.opentelemetry.instrumentation/opentelemetry-logback-appender-1.0 {:mvn/version "2.6.0-alpha"}
           ...
           }
 ...
 }

and then deregister the console log appender and create an OpenTelemetry one

(ns restaurant
    ...
    (:import (ch.qos.logback.classic Level Logger)
             (io.opentelemetry.instrumentation.logback.appender.v1_0 OpenTelemetryAppender)
             (org.eclipse.jetty.server Server)
             (org.slf4j LoggerFactory))
    ...)

(defn configure-open-telemetry-logging []
      (let [context (LoggerFactory/getILoggerFactory)
            ^Logger logger (.getLogger context Logger/ROOT_LOGGER_NAME)]
           (.detachAppender logger "console")
           (let [open-telemetry-appender (doto (OpenTelemetryAppender.)
                                               (.setContext context)
                                               (.setCaptureCodeAttributes true)
                                               (.start))]
                (doto logger
                      (.setLevel Level/INFO)
                      (.addAppender open-telemetry-appender)))))

...

(defn -main [& _args]
      (configure-open-telemetry-logging)
      ...)

(comment
  (configure-open-telemetry-logging)
  ...
  )

It's marginally disconcerting that logging now takes up almost exactly half of the service's code, but to be fair, the rest is just managing the HTTP server.

The important information

I added the OpenTelemetry Java agent to my Dockerfile and added the required arguments to attach to the JVM as the service started.

FROM clojure:temurin-21-alpine AS builder

RUN mkdir -p /artifacts
RUN wget -O /artifacts/opentelemetry-javaagent.jar https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.6.0/opentelemetry-javaagent.jar

...

FROM eclipse-temurin:21-jre-alpine AS final

...

COPY --from=builder --chown=clojure:clojure /artifacts/opentelemetry-javaagent.jar /service/opentelemetry-javaagent.jar

...

CMD ["java", "-javaagent:/service/opentelemetry-javaagent.jar", "-jar", "/service/restaurant.jar"]

For production traces I like Honeycomb (I'm also a big fan of Honeycomb's CTO Charity Majors, especially her perspectives on observability and socio-technical systems. I highly recommend perusing her blog when you have a chance.) They've got a free plan of 20 million events per month, which is more than enough to get started with. Having signed up I used flyctl to store the sensitive env vars that required my API key

fly secrets set OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=<API key>"
fly secrets set OTEL_EXPORTER_OTLP_METRICS_HEADERS="x-honeycombb-team=<API key>,x-honeycomb-dataset=restaurant"

and configured the rest of the required environment variables in my fly.toml configuration

[env]
  OTEL_TRACES_EXPORTER = 'otlp'
  OTEL_METRICS_EXPORTER = 'otlp'
  OTEL_EXPORTER_OTLP_ENDPOINT = 'https://api.honeycomb.io'
  OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'https://api.honeycomb.io/v1/traces'
  OTEL_EXPORTER_OTLP_METRICS_ENDPOINT = 'https://api.honeycomb.io/v1/metrics'
  OTEL_SERVICE_NAME = 'restaurant'

The service was redeployed and logs and traces were being sent to Honeycomb.

Summary

The last six months (on and often off) has basically resulted in one line of code, flyctl deploy --config infra/fly.toml. It's pretty humbling, knowing I'm still perfectly capable of barreling headlong into a rabbit hole far enough that the diggers need to be called in.

The restaurant service is now running in production, re-deployed every time a new change is pushed to main and is pushing traces and logs through OpenTelemetry to Honeycomb.

Next time we'll finish off the check-list, "Turn on all the error messages". See you then. Hopefully somewhat sooner than this time.

Published: 2024-07-27

Tagged: clojure infrastructure

Modern software engineering for a small team

Why, oh why, oh why?

I'm a software engineer with two decades in the software industry. Over those two decades I've spent most of my time in "brown field" code bases, code bases where I wasn't around when they were first started. I've also been working inside larger ecosystems that provide a lot, if not all, of the surrounding infrastructure (e.g. integration and deployment pipelines, databases, logging, etc.). This means that I rarely spin up new projects, nor do I fully understand the fundamentals that underpin the tooling that is required to support those products. That tooling is abstracted away and my use of it is reduced to an API call or copy, paste and small modification of a config file. This is great for working in that particular ecosystem, but doesn't help me understand what is going on under the hood. While this understanding clearly isn't a requirement to do my job (or I'm doing fantastically well to "faking it until I make it") it's gotten to the point where I really would like to understand what is required and what is going on.

To explore these cracks (or more likely crevasses) in my understanding my plan is to work through a project from scratch using what I believe to be modern software engineering techniques and processes as if I were bootstrapping a new product with a small team. I hope to take the simplest (but not always the easiest) route possible and eschewing certain technologies until their introduction reduces the complexity of the product. For example, I've skirted the edges of more infrastructure focused technology (e.g. Terraform and Kubernetes) and so will start without them and intend to bring them in only if the additional complexity simplifies the product overall.

A lot of my recent thinking about software engineering is underpinned by Accelerate by Nicole Forsgren, Jez Humble and Gene Kim. I expect I will often refer to the capabilities described there during this series of blog posts.

My years at the coal face have also exposed me to many ideas that would like to try, but which I haven't found space to try out in a professional setting. I intend to experiment with them here, as and when appropriate, no doubt making mistakes and hopefully rectifying them in future installments.

What am I building?

Normally when I'm trying out new languages, processes and/or tools I'll build the requisite contacts or todo-list application. The problem with those specific projects is that the vast majority of the code ends up being request and response handling, authentication, database wrangling and the like, and very little actual business logic. While this is fine for experimenting with new languages or tools, modern software engineering is about building software that continuously evolves. That non-business logic scaffolding tends to be the most static part of the application from an engineering perspective, so I'm looking for a product with plenty of business logic that can evolve over time.

I've just started reading Mark Seemann's book Code That Fits in Your Head which apparently includes a restaurant reservation system that he builds throughout the book, starting simply and modifying and adding new features as new use cases present themselves. Exactly the sort of product I'm looking for.

Presumably there's a plan?

The example application in Code That Fits in Your Head is written in C#. My current tool of choice is Clojure so as I read through each chapter I will build a Clojure application using the requirements defined in that chapter. While the code for this blog series will be in Clojure I hope to explore some wider software engineering thoughts and opinions I have. Hopefully this means this series will be useful to more than just Clojurists.

As an aside, this isn't a beginners guide to Clojure. If you're new to Clojure and need some resources to get started then Practicalli, the Getting Started section of the Clojure website and Clojure for the Brave and True are all excellent starting points.

Check it out

The first practical advice in Code That Fits in Your Head provides is to use checklists. Having read Atul Gawande's The Checklist Manifesto and agreed with most of it, I'm all on board for this. The book recommends the following checklist for starting a project

A checklist isn't a detailed list of everything that needs to be done, nor is it a list of requirements. It's just a reminder of the high level, important things that need to be done. Nor is it static, checklists are expected to be personalised and evolved over time. To me, this seems like a perfectly reasonable starting point.

First things first

Code That Fits in Your Head starts off by creating a web server that simply returns "Hello World!" to every request, so that's where I'll start.

Project management

Of the 3 main project management tools available for Clojure, (deps.edn, leiningen and boot) the most I've had experience with recently is deps.edn, so I'll go with that. Sean Corfield's deps-new is one option for generating projects from templates, but in this instance I'm going to do it by hand.

A minimal web server

To start with I'll need a minimal directory layout

restaurant/
├── deps.edn
├── src
│   └── restaurant.clj

There are two primary mechanisms for server side applications in Clojure, ring and pedestal. I haven't used pedestal in anger and the ring ecosystem is rich and vibrant so that's the one I'll be working with. With that in mind, I'll add the following to my deps.edn file.

{:paths ["src"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        ring/ring-jetty-adapter {:mvn/version "1.11.0"}}}

As mentioned above, the current requirement is for a web server that simply returns "Hello World!" as the response to any requests. The easiest way to do this is as follows

(ns restaurant
  (:require [ring.adapter.jetty :as jetty])
  (:gen-class))

(defn -main [& _args] (jetty/run-jetty (fn [_] {:status 200 :body "Hello World!"}) {}))

While this code will work in production (once I've built and deployed the service) I can't run it locally as a normal user, either in the REPL or on the command line, because it will attempt to bind to port 80 which normal users cannot do. Assuming I'm not running anything else on port 80, I can escalate my privileges and check that it works. On my linux box that's

$ sudo clojure -M -m restaurant

and I can then access the server using curl (or your command line web client of choice).

$ curl localhost
Hello World!

Although it works, this is a pretty hostile situation for a developer, especially as complexity increases.

Getting Git'ty with it

Now that I've got something working I check my checklist and the first thing I find is "Use Git". With that in mind I do the following:

Where'd my branch go?

One of the capabilities outlined in Accelerate is "Trunk-based development". This means either working with very short-lived feature branches or no branches at all. Because I'll be the only person working on this product I intend to go with no branching at all. Once I start deploying to production I'll need some safeguards, but for now I'll commit straight to the trunk in as small commits as make sense.

Simplicity itself

While the implementation of the server technically works, I tend to follow the mis-quoted aphorism that "code should be as simple as possible, and no simpler". I actually think this code is too simple. It doesn't clean up after itself nor is it developer and REPL friendly. With that in mind I extract out start-server and stop-server functions and gracefully shut down the server when the service is shut down.

(ns restaurant
  (:require [ring.adapter.jetty :as jetty])
  (:import (org.eclipse.jetty.server Server))
  (:gen-class))

(defn start-server
  ([] (start-server {}))
  ([config] (jetty/run-jetty (fn [_] {:status 200 :body "Hello World!"}) config)))

(defn stop-server [server]
  (.stop ^Server server))

(defn -main [& _args]
  (let [server (start-server)]
    (.addShutdownHook
      (Runtime/getRuntime)
      (Thread. ^Runnable (fn [] (stop-server server))))))

(comment
  (def server (start-server {:port 3000 :join? false}))
  (stop-server server))

By passing in config I can now use the default config for production and modify it simply at development time. Here I run the server on port 3000 and setting :join? to false means the server doesn't block our REPL thread.

What's that noise?

Great. I can now start and stop our web server in the REPL, a much friendlier developer experience. The only problem is that when I do start the application/REPL I get the following warning

SLF4J: No SLF4J providers were found.
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See https://www.slf4j.org/codes.html#noProviders for further details.

Looks like our server uses SLF4J for logging, so I should provide a logger implementation. I could use the no-op logger, but I tend to like being informed when stuff goes wrong. With that in mind I use the SLF4J SimpleLogger by adding it to our dependencies

{:paths   ["src"]
 :deps    {org.clojure/clojure {:mvn/version "1.12.0-alpha5"}
           org.slf4j/slf4j-simple {:mvn/version "2.0.10"}
           ring/ring-jetty-adapter {:mvn/version "1.11.0"}}}

Now when I start the application I no longer get the warning. Once I start the server, however, we get the following log messages

[nREPL-session-aca1af76-ccda-46d5-b745-63e29dc26d52] INFO org.eclipse.jetty.server.Server - jetty-11.0.18; built: 2023-10-27T02:14:36.036Z; git: 5a9a771a9fbcb9d36993630850f612581b78c13f; jvm 21.0.1+12-29
[nREPL-session-aca1af76-ccda-46d5-b745-63e29dc26d52] INFO org.eclipse.jetty.server.handler.ContextHandler - Started o.e.j.s.ServletContextHandler@cc0cd0{/,null,AVAILABLE}
[nREPL-session-aca1af76-ccda-46d5-b745-63e29dc26d52] INFO org.eclipse.jetty.server.AbstractConnector - Started ServerConnector@4ce608d3{HTTP/1.1, (http/1.1)}{0.0.0.0:3000}
[nREPL-session-aca1af76-ccda-46d5-b745-63e29dc26d52] INFO org.eclipse.jetty.server.Server - Started Server@36b520a7{STARTING}[11.0.18,sto=0] @23056ms

While this information can be useful, unless I'm being warned about something I don't want this noise distracting me, and potentially hiding more important information, during development. I still want to know about warnings and errors, so I need to change the minimum logging level to Warn. There's a couple of ways to do that, but my preferred is to do it programmatically.

Introducing "dev"

Clojure has a concept of aliases to add or remove functionality depending on the situation. In this case, when I'm developing I want to set the minimum log level to Warn. I add an alias called dev to deps.edn

{:paths ["src"]
 :deps {org.clojure/clojure {:mvn/version "1.12.0-alpha5"}
        org.slf4j/slf4j-simple {:mvn/version "2.0.10"}
        ring/ring-jetty-adapter {:mvn/version "1.11.0"}}
 :aliases {:dev {:extra-paths ["dev"]}}}

and a dev/user.clj file to our project

restaurant/
├── deps.edn
├── dev
│   └── user.clj
├── src
│   └── restaurant.clj

The dev/user.clj file will be loaded when the REPL starts, so I set the minimum log level in there

(ns user
  (:import (org.slf4j.simple SimpleLogger)))

(System/setProperty SimpleLogger/DEFAULT_LOG_LEVEL_KEY "Warn")

Now when I start the server from the REPL I no longer get the information log lines, leading to a cleaner development experience.

Summary

In this first blog post I talked about why I'm creating this series, how it will (hopefully) demonstrate modern software development techniques and processes and I built a simple, first cut of a web server. The project so far can be found in a GitHub repository. In my next post I'll look at the second item on the checklist, "Automate the build". If you've got comments or questions feel free to reach out via Twitter/X (linked above) or on the Clojurians Slack channel.

Published: 2024-01-16

Tagged: clojure restaurant

My first blog post

So this is my first post. Not the first first post I've written, but maybe this time it'll stick (hopefully). I'm using quickblog from the incomparable borkdude. Once I had followed the instructions in the README to get a skeleton first post up, I headed over to the Anders Means Different blog for instructions on how to get my blog published on GitHub pages using GitHub Actions.

Having copied the yaml workflows file to my blog's .github/workflows/ directory, the initial deployment failed. This is because I had failed to set the `Build and Deployment: Source` as "GitHub pages", rather than "Deploy from a branch", in my blog's repo.

Once I successfully re-ran the deploy action I could reach my shiny new blog at https://hughpowell.github.io/blog. GitHub automatically serves the site at .github.io/.

Having got everything up and running on GitHub pages I now wanted to see if I could have it served from a custom domain. Thankfully that's pretty straight forward, you just need to create a CNAME record in your DNS host. In may case this was pointing from blog.hughpowell.net to hughpowell.github.io/blog. The last thing was to tell GitHub that I was using a custom domain and wait for it to pick it up and generate an HTTPS certificate for my custom domain.

That's all for now. In my next post I plan to start working through Mark Seemann's book Code that fits in your head and translating the examples into Clojure.

Published: 2024-01-05

Tagged: quickblog

Archive