Writing a Guix service from scratch, as beginner

I am in the middle of migrating my machines from Nix to Guix.

My first milestone is to migrate my VPS to Guix.

My VPS has essentially three majors roles:

  1. host my email server.
  2. host my wireguard server.
  3. act as a proxy server to access services on my home server (thus, hiding my home IP).

The mail server is up and running, using exim as the MTA and dovecot as the IMAP server. I had to jump through a few hoops to get exim up and running. I would have preferred postfix over exim, but only exim is available out of the box.

At the time, I didn't know how to define custom services. Which brings us to the motivation behind this article.

As I intend to use Guix for the foreseeable future, I want to build a deep understanding of Guix. Learning to create my own services, suited to my own needs, is an important part of it.

For the proxy server, I enjoy using Caddy. Caddy isn't available as a service on Guix (not available 'yet'! I saw a PR to bring Caddy to Guix)

This seems like the perfect time to finally dive into custom services.

As I navigated my way through the docs and the source code, I decided to document my learning and share it here, as guide.

In this article, we will be writing a custom service for our Caddy reverse proxy.

This service involves setting up setting up the configuration file, creating a system user and configuring shepherd to run the daemon.

It's a perfect first custom service. It is simple enough yet it includes a good overview of custom services.

Indeed, most of the services I run on my home server are essentially a combination of those three actions: configure users, manage configuration files and run the daemon.

Let's get started.

Who is this for?

This article is suitable to Guix newcomers and beginners. As a matter of fact, I wrote this as I went along, stumbling my way through it.

Pre-requirements

The only true prerequisite is to have the Caddy package ready to go on your Guix instance At the time of writing this blog, the Guix repository doesn't contain the caddy package but the team is working on it.

In the meantime, you can follow my blog post to create one easily. It only takes a few lines of code.

You don't need to have a high proficiency or deep expertise in Scheme (I certainly don't), but a basic understanding of it is highly recommended.

I assume that you are, like me, fairly new to Guix - however this isn't a replacement to the documentation. I will be paraphrasing the documentation a lot with my own interpretation of it. I encourage you to keep the documentation while following this article and read along.

I obviously assume that you have Guix installed and a working configuration. If not, you can find one here: https://guix.gnu.org/manual/devel/en/html_node/Using-the-Configuration-System.html

Finally, you need to know how to reconfigure your system (hint: sudo guix system reconfigure my-config.scm).

When I reference source code, I will use the notation file-name:line.

The plan

Alright, with this out the way, let's talk about what we are concretely going to build here.

In order to keep things as simple as possible, I decided to define our service to be as simple and bare-bones as possible:

  1. Define an empty service, and successfully "enable" it (<service-type> record).
  2. Create the dameon user and group (account service).
  3. Copy the Caddy configuration to the right location (activation service).
  4. Configure Shepherd to run the service as a dameon in the background (shepherd service).

This is the bare mininum Caddy service; it will do the job, but it won't be the most elegant.

Most of the configuration options will be hard coded; the service won't be ready to be distributed. However, it will be functional and fulfill our needs.

Once completed, we can expect to have accumulated enough knowledge along the way to revisit it, refactor and improve it.

As soon as this article is out, I plan on improving it and if I feel brave, I might even propose it to the Guix repository.

But for now, let's stick to the basics. We don't want to let perfection get in the way of done.

What is a service?

The documentation defines a service as "something that extends the functionality of the operating system".

This definition is quite vague as a service can encompass a lot of different functionalities. The most obvious type of a service is a daemon running a process in the background (our case), but it's not the only type of service.

As we will see, a service can be a process which is run once, such as creating accounts or copying files to the store. It can be a recurring process, such as a cron job. It can also be "instructions" to the Shepherd process.

In a way, it is a primitive to configure our system in a declarative manner.

One interesting aspect of services, is that they are designed from the ground up to be extensible and composable. A service can extends one or more services, and can also be extended by one or more services. We will dive a deeper on that subject further below; I find this architecture fantastic.

You can read more about this in the documentation. If you still don't quite understand the implications of this architecture, it's completely normal. It didn't click for me until I started writing this Caddy service.

So if you don't fully grasp what a service is yet, follow along, it will make more sense as we progress.

1. Define an empty service

Our first step is to break the ice by creating a the simplest service we can: a bare service that does nothing. The goal is to get familiar with services.

First, we need to define the service type of our service.

A service type is essentially a blueprint for a service. Think of it as what a class is to an instantiated object.

A service is "instantiate" with the service procedure. It takes as an argument a <service-type> record and a value.

(service my-service-type my-service-value)

If a value isn't provided, the default-value defined in the <service-type> definition is used.

(service my-service-type) ;; this service is using the default-value

To enable the service, we add it to the list of services provided to the services procedure, which takes a list of service as an argument.

(services
  (append (list (service my-service-type my-service-value)) %base-services))

Now let's look inside the <service-type> record.

You can find the definition of <service-type> in gnu/services.scm:187. The service type is also well documented, but grepping the codebase is very valuable tool when working with Guix (or any other software if you ask me!).

(define-record-type* <service-type> service-type make-service-type
  service-type?
  (name       service-type-name)                  ;symbol (for debugging)

  ;; Things extended by services of this type.
  (extensions service-type-extensions)            ;list of <service-extensions>

  ;; Given a list of extensions, "compose" them.
  (compose    service-type-compose                ;list of Any -> Any
              (default #f))

  ;; Extend the services' own parameters with the extension composition.
  (extend     service-type-extend                 ;list of Any -> parameters
              (default #f))

  ;; Optional default value for instances of this type.
  (default-value service-type-default-value       ;Any
                 (default &no-default-value))

  ;; Meta-data.
  (description  service-type-description)         ;string
  (location     service-type-location             ;<location>
                (default (and=> (current-source-location)
                                source-properties->location))
                (innate)))

We observe that the <service-type> requires a name and a list of extensions. It also contains optional fields: compose, extend, default-value, description and location.

Let's set the name of our service to 'caddy. We also set the extensions to nil (the empty list), which means that we aren't extending any existing services. We also leave the default-value to nil.

Regarding the optional fields, let's only care about the description for now. It takes a string and is used for - you guessed it - the description.

I will cover the compose, extend and extensions later so don't worry about them for now.

Now our <service-type> for our Caddy service looks like this:

(define caddy-service-type
 (service-type
   (name 'caddy)
   (extensions nil) ;; an empty list
   (default-value nil)
   (description "Caddy is an extensible server platform that uses TLS by default.")))

That's it. This is our simplest service type: it only has a name and a description.

Let's install it by adding it to the services procedure.

;; config.scm
(use-modules (gnu))
(use-service-modules networking ssh)
(use-package-modules screen ssh)

(operating-system
  (host-name ...)
  (timezone ...)
  (locale ...)
  (bootloader ...)
  (file-systems ...)
  (users ...)
  (packages ...)
  (services (append (list (service dhcpcd-service-type)
                          (service openssh-service-type
                                   (openssh-configuration ...)) ;; Pre-existing services
              (service caddy-service-type)) ;; <-- Add this line here
                    %base-services)))

Now, run sudo guix system reconfigure config.scm and celebrate your first custom service.

We "instantiated" a service of type caddy-service-type without a value, defaulting to the default-value of nil.

It's not doing anything, but it still is a service.

If you have a graphical display, you can run the following command to generate a graph of your system:

guix system extension-graph config.scm | guix shell xdot -- xdot -

Your Caddy service will be present in your services. Well done. Now let's make that service do something useful.

2. Create the daemon user

The next step to improve our service is to instruct our service to create the caddy user and group.

It is always a good idea to create a non-root user to run a background process: if the process is compromised, the area of attack is much smaller.

This user will:

  1. Start the daemon.
  2. Have read acess to the caddy

In order to create the user and group, we will use the power of extensions.

Guix contains a large number of services out of the box; one of them is the account-service-type. This service is used to create users and groups on our operating system.

In order to use that service, we will need to add it to the extensions field of our caddy-service-type.

The extensions field is a bit hard to grasp at first. My first instinct was that the extensions field allows you to modify your service by extending its own capability, giving extra functionality. In this case, adding to our caddy-service-type the capability of creating users and groups.

However it is the opposite: you don't "add" capability to your service-type, you instruct the target service-type to "do more". How the target service-type is modified is following the rules defined in its compose and extend fields. Don't worry about it for now, we'll go over it further below.

Let's continue on our account creation. We want to create a user, caddy, and a group with the same name, caddy.

In order to instructs the account-service-type to create those users when our caddy-service-type is installed, we extend the accounts-service-type.

This is the beauty of this architecture. It makes the system completely declarative: if the Caddy service is installed, then Guix create those accounts. If not, we don't. The declaration lives within the Caddy service, not in the account service. This is why Guix is a declarative operating system.

It's a nice architecture when you think about it, it reminds me of the principle of Inversion of Control.

The account-service-type is declared as extendable service-type and offers an interface to create accounts. This makes it self-contained. The account-service-type doesn't need to know who is creating accounts and why; it only offers an interface to hook into.

As you can tell, I really like this architecture.

We now understand that the extensions field allows you to 'extend' a target service-type to, for example, create more users, but we still don't really know how to specify it. We have a few ways of finding out set it up: reading the docs, grepping examples, or looking at the source.

For the sake of getting better at understanding services, let's look at the source.

Deeper dive: extend and compose

Being curious and eager to really assimilate how services extensions work, I tried find out if we can guess the shape of the accounts by looking at the account-service-type service, defined in shadow.scm:547.

(define account-service-type
  (service-type (name 'account)
                ;; Concatenate <user-account>, <user-group>, and skeleton
                ;; lists.
                (compose concatenate)
                (extend append)
                (extensions
                 (list (service-extension activation-service-type
                                          account-activation)
                       (service-extension shepherd-root-service-type
                                          account-shepherd-service)
                       ;; Have 'user-processes' depend on 'user-homes' so that
                       ;; daemons start after their home directory has been
                       ;; created.
                       (service-extension user-processes-service-type
                                          (const '(user-homes)))
                       (service-extension etc-service-type
                                          etc-files)))
                (default-value '())
                (description
                 "Ensure the specified user accounts and groups exist, as well
as each account home directory.")))

The first thing we can see, is that the default value is an empty list, which means that the account-service-type itself is expecting a list as a value. But a list of what?

In this case, the comments give us the answer: a list of <user-account> and <user-group>. If it was not for the comments, you could look at the account-activation and find out there as well.

Before moving on, let's talk about compose and extend fields. This is how they are defined in the documentation

> compose

This is the procedure to compose the list of extensions to services of this type.

> extend

This procedure defines how the value of the service is extended with the composition of the extensions.

This is a bit vague and left me a bit scratching my head, but what it means is that:

  1. Compose defines how to the service compose multiple services extending this service-type.

    For example,if we have 3 services listing 'accounts-service-type' as an extension, what do we do? Do we only take the first? The last?

    In this case, If three services are defining users by extending the account-service-type, we want to create all those users. We want to concatenate the 'extending' services.

    We can imagine other services behaving differently. For example, let's imagine a service whose role is to manage a port. Let's call it open-port-22-service-type. Let's also imagine that two service extends this service-type; one wants to open the port, the other close it.

    What shall we do? Open it, or close it? We have conflicting values.

    Maybe we want to err on the caution side and define open-port-22-service-type to prioritise 'close' values if it receives contradicting values.

    That's what the compose field is for. It defines how to compose multiple extending service values.

  2. Extend defines how the composition from (1) interact with the service.

    It is, in a way, similar to compose but with the service-type's value itself.

Let's imagine a service-type which displays a welcome message when the computer boots, called welcome-message-service-type. If a service extends it with a different message, we would want to give priority to the extending service value.

In this case, we would use extend to instruct the welcome-message-service-type to replace its own value with the composed values from the extending services.

Note that compose and extend accept procedures which takes the service itself as an argument. It gives the user a lot of freedom and power to define the rules based on the state of the service.

Let's finish with the user-account-service-type as a final example.

In the case of users, we want to:

  1. compose: concatenate the values: we create all the users and groups received.
  2. extend: append to the service value: we create all the users and groups received AND the ones specified by the user-account-service-type service itself (remember that we can instantiate the service with a value - we wouldn't want our value to be overriden when the service is extended).

Zooming back to the architecture, we can see that services are all built upon each other. A service, when installed, will modify the services listed in its extensions field. Those will in turn modify the service-type of their own extensions.

This chain reaction will continue until all services are collected in the system-service-type and handled, I assume, by the Guix daemon.

In other words, under the hood, we end up with a collection of all our services in one single record.

Phew, that's quite a bit to assimilate. If it doesn't quite click yet, keep it in the corner of your head. It did take me a while to start to assimilate the concept of Guix services. Keep calm and carry on.

We now know the account-service-type:

  1. Accept a list of <user-account> and <user-group> records
  2. Concatenate any values received as per its compose rule.
  3. Append the (concatenated) received values to its own value, as per the extend rule.

Back to our <user-account> and <group-account>

So, by either following in my deep dive, reading the documentation or looking up examples in the code base, we found out that our group and user are defined as a <user-account> and <user-group> respectively.

Both records can be found in accounts.scm at line 71 and 90.

We define our accounts like so:

(define %caddy-accounts
  (list (user-account
          (name "caddy")
          (group "caddy")
          (comment "Run the caddy daemon")
          (system? #t)) ;;<-- the caddy user is a system user, not a real user
        (user-group
          (name "caddy")
          (system? #t))))

Now let's add the account-service-type extension.

The extensions procedure accepts a list of <service-extension>. A service-extension is a record that accept two parameters.

The first is the name of the target service-type (account-service-type in our case). The second parameter to a service-extension is a procedure that takes the the service itself (the current caddy service) and returns a list of object to extend the target service (see documentation).

The second parameter seems complicated, but it's simply a procedure that returns the "value". In our case, a list of user and group account. You can imagine that we could create the user based on the Caddy configuration, if we wanted make our service configurable.

Let's define our service-extension for the account-service-type and add it to the extension field.

(define caddy-service-type
  (service-type
    (name 'caddy)
    (extensions (list (service-extension account-service-type
                                         (const %caddy-accounts))))
    (description "Caddy is an extensible server platform that uses TLS by default.")))

We used const here, which is the same as doing (lambda (_) (%caddy-accounts)).

Alright, time to finally test it out!

Run sudo guix system reconfigure config.scm to apply our changes.

Let's check the presence of the caddy user by running sudo id caddy.

uid=982(caddy) gid=976(caddy) groups=976(caddy)

Nice! Our caddy user and group were created!

What we have learned so far:

  • Services are 'things' that modify the system.
  • Services can be extended and can extend other services.
  • Services define how they can be extended, via the compose and extend fields.
  • Services define the service they extend, via the extensions field.
  • All services are eventually "collected" in the system-service-type, the root service.
  • We have learn to extend the account-service-type to create a user and a group for our service.

We have also learn to dig around the source code to learn more about services.

Now let's see how we can write the config, the Caddy file, into /etc/caddy.

3. Copy the caddy configuration file to the right location

In order to keep matters simple,the caddy-service-type won't be in charge of generating the Caddyfile1.

We will write the Caddyfile manually and provide it to our Caddy service. The Caddy service will simply copy it to the right location.

The target location for Caddyfile is /etc/caddy/caddy.conf.

As always, we will be leveraging extensions to improve our service. To execute an arbitrary procedure when a service is enabled, we can use the activation-service-type.

The activation service is a service that is executed at activation of our service. It's a widely used service; a lot of the service in the guix repository rely on it.

Interestingly enough, I couldn't find much about it in the documentation2, except in this example. Let's look at the source code instead.

In services.scm:792 we get a bit more information:

>  ;; The activation service produces the activation script from the gexps it
  ;; receives.

We can also look at its definition services.scm:776

(define activation-service-type
  (service-type (name 'activate)
                (extensions
                 (list (service-extension boot-service-type
                                          gexps->activation-gexp)
                       (service-extension system-service-type
                                          activation-profile-entry)))
                (compose identity)
                (extend second-argument)
                (default-value #f)
                (description
                 "Run @dfn{activation} code at boot time and upon
@command{guix system reconfigure} completion.")))

Alright, we see (from the comment and the lambda gexps->activation-gexp) that the value required by a service is a gexp.

What is a gexp? A gexp, or g-exp, or G-expression3 is a Guix concept of 'packaging' code to be executed later in the build environment.

If you know what a G-expressio is, you can skip the next section. If not, let's briefly introduce the concept of G-expression4.

Shallow dive: G-exps

Note: I won't go into the nitty gritty of G-exps; there is a lot to cover. We will keep a high level overview.

To understand what G-expression are, you need to know that Guix runs in two environments.

When building packages, the guix daemon creates something akin to containers. There aren't quite containers as far as I undestand, but the concept is close enough for our understanding.

To ensure reproducibility, each package is built in its own clean "containers" that only has the required inputs. For this to happen, part of the "code" we define needs to be staged to be executed in this container, at buid time. In essence, G-expressions is a way of staging code to be executed at build time.

These are the two environments of Guix:

  1. The "host" environment, in which we define packages and configurations (what we are doing here).
  2. The "build" environment, in which the guix daemon is performing the actual build actions.

G-expression are written with the following macro #~( ;; some code ;; ). Whenever you see the macro #~, it means that the code will be only executed inside the build container.

Inside a G-exp, we can force the evaluation of code using the #$ macro. For example, let's have a look at this gexp:

#~(begin
     (mkdir #$output)
     (mkdir "/another/folder/path"))

In this instance, when the code is evaluated #$output will be replaced (or ungexp) and the expression will be:

#~(begin
     (mkdir "/the/output/dir")
     (mkdir "/another/folder/path"))

In concept, it's similar to the quasi-quote and comma in Scheme[TODO: insert the link to quasi quote and comma]

As the G-expression is evaluated in a different environment, it will not have automatically access to the modules imported in the current environment. In order to give the G-expression access to modules, we need to import them in the "build" environment in which the G-expression is evaluated.

To do so, we use the with-imported-modules syntax like so:

(with-imported-modules '((guix build utils))
  #~(begin
      (use-modules (guix build utils))
      (mkdir-p "/the/output/dir") ;;mkdir-p is a utility found in guix build utils.
      (mkdir-p "/another/folder/path")))

In the example above, mkdir-p is a procedure from in guix/build/utils.scm. The with-imported-modules provides the modules to the G-expression environment. We can then use the standard use-modules syntax as we would in a normal Scheme environment.

There is a lot more to G-expressions, but it merits its own article so I won't go any further.

This is all we need to know at this stage:

  1. G-expression is code staged to be executed in the "build" phase of Guix.
  2. G-expression are written with the #~ macro.
  3. We can ungexp part of the G-expression with the #$ macro.
  4. We can import modules into the G-expression with the with-imported-modules syntax.

If you want to dig deeper, some excellent resources are:

Back to the activation service

With this newly acquired knowledge, let's think about what we are trying to achieve here.

We want to:

  1. Create the /etc/caddy directory.
  2. Copy the configuration file at /etc/caddy/caddyfile.
  3. Ensure that the configuration file is only readable by the caddy user, not modifiable.

We need to express those three instructions in a G-expression that we will pass as an argument to the service-extension for the activation-service-type.

NOTE: in the time spent between writing and publishing this article, I found out that there is a service that allows us to copy file directly etc-service-type. However I found going through the activation-service-type a valuable learning path. I will follow up with a second article in how to use the etc-service-type, but if you're new to Guix, I encourage you to follow the activation-service-type method presented below as it gives you a better understanding of the underlying system.

This is the G-expression I came up with, with comments explaining each line.

(define %caddyfile (plain-file "CaddyFile" ":8080 { respond \"Hello world!\"}"))
;; Generate a plain caddyfile configuration.
;; Refer to https://caddyserver.com/docs/caddyfile-tutorial for more information

(define (%caddy-activation _)
  (with-imported-modules '((guix build utils))
    #~(begin
        (use-modules (guix build utils))
        (mkdir-p "/etc/caddy") ;; 1. Create the directory
        (copy-file #$%caddyfile "/etc/caddy/caddyfile") ;; 2. Copy the configuration file at the right location
        (chown "/etc/caddy/caddyfile"
               (passwd:uid (getpwnam "root")) ;; 3. Change the ownership to root:caddy.
               (group:gid (getgrnam "caddy")))
        (chmod "/etc/caddy/caddyfile" 750)))) ;; 4. Set permissions

You notice that the %caddy-activation procedure accepts an argument. As discussed earlier, it can receive the current service as an argument, allowing you to dynamically modify the output. For example, you could imagine giving the caddy-service-type the ability to configure the caddy username.

Ok, let's try it out:

(define caddy-service-type
  (service-type
    (name 'caddy)
    (description "Run Caddy, the simple proxy server.")
    (extensions
     (list (service-extension account-service-type (const %caddy-accounts))
           (service-extension activation-service-type %caddy-activation) ;; <- Adding the extension here.
    (default-value nil)))

Now run sudo guix system reconfigure config.scm.

Inspect the content of /etc/caddy/caddyfile: sudo cat /etc/caddy/caddyfile

> :8080 { respond "Hello world!"}

We can test it out by starting caddy manually: =sudo caddy -c /etc/caddy/caddyfile"

In a second terminal, run curl localhost:8080. You should obtain the response "Hello world!".

We now have successfully created a service to run caddy! This concludes the configuration of our service: our service create the daemon user and group, create the caddy configuration file and copy it to the right location.

The last part is about making it run as a dameon managed by shepherd in the background, so we don't need to start it manually.

4. Configure shepherd to run the service in the background (shepherd service).

Our custom caddy service creates a user, a group and the caddy configuration file. The last part to have a fully functional service is to instruct shepherd to manage the daemon.

We should by now have a hunch of what needs to be done: we need to extend the Shepherd service to run our dameon.

Before we do this, we need to do a final detour and talk about privileged ports.

Privileged users and privileged programs

On most linux distributions, including Guix, any port numbers below 1024 are special. Standard user are not allowed to bind process to them.

Since our caddy user is not a privileged user, it won't be able to run the caddy server on port 80 (http) and 443 (https).

We need to allow the caddy process to be run on those ports. The way to go about this in Guix is to use privileged programs. To make Caddy a privilived program, we append it to the %default-privileged-programs and assign it the extra capabilities.

(privileged-programs
  (append (list (privileged-program
                 (program (file-append caddy "/bin/caddy"))
                 (capabilities "cap_net_bind_service=ep")))
          %default-privileged-programs))

If you're extra curious, you can take a peek at the list of privileged-programs in system.scm:1267. It contains a lot of well known utility requiring extra privileges, such as sudo, su, passwd, ping or mount

Here we are adding the capability CAPNETBINDSERVICE as described in man 7 capabilities as Bind a socket to Internet domain privileged ports (port numbers less than 1024).

The =ep means that capability is *e*ffective and *p*ermitted, essentially giving it the capability there and then.

Note that when we make a program privileged, it is relocated outside of the store. The store cannot contain privileged for security concerns [TODO: insert doc here].

The privileged programs are located in /run/privileged. Our 'privilged' Caddy program is located at /run/privileged/bin/caddy.

Configuring Shepherd

The next step is to configure Shepherd. To do so, we need to extend the shepherd-root-service-type with a shepherd-service record.

In this case, the Shepherd service documentation is very helpful and detailed. I highly recommend you to read it, it goes into detail on all the options available.

I don't think there is a lot of value here in me of regurgitating the documentation.

Intead, I will comment my intention on the extensin directly.

(define (%caddy-shepherd-service _)                                 ;; We ignore the config argument as we will not use it.
  (list (shepherd-service
          (provision '(caddy))                                      ;; this is purely an arbitrary naming handle.
          (documentation "Run the caddy daemon")                    ;; self explanatory
          (requirement '(networking user-processes))                ;; We instruct shepherd to start the caddy daemon after the networking and user modules.
          (start #~(make-forkexec-constructor                       ;; the command to start the process: note the use of g-exps! Refer to the doc for an exhaustive list of options
                    (list "/run/privileged/bin/caddy"               ;; Note the location of our privileged version of caddy. The "run" commands and its arguments are Caddy related; refer to the caddy documentation
                          "run" "--config" "/etc/caddy/caddyfile"
                          "--adapter" "caddyfile")
                    #:user "caddy"                                  ;; The user and group in charge of the daemon
                    #:group "caddy"
                    #:log-file "/var/log/caddy.log"
                    #:environment-variables                         ;; Required for the logs
                    '("HOME=/var/lib/caddy")))
          (stop #~(make-kill-destructor)))))                        ;; The destructor, called when the process is killed. Refer to the documentation

[TODO: add the service extension here]

A few point worth mentioning:

  • 'user-processes is a requirement, as mentioned in the documentation [TODO: insert documentation]. We need the file systems for the user to be mounted before starting caddy.
  • 'networking is also set as a requirement, we want the network stack up before we start caddy. I find this while looking at how the exim service was written. As far as I can tell, these aren't documented. You will need to look directly in the source code.
  • I don't quite recall why I set up the environment variable, but I believe it is required it to write the logs. The service would otherwise it would complain. It is mentionned in the docs when using systemctl.

Alright, time to start it: sudo guix system reconfigure config.scm

Once started, we can check the health of our daemon using the herd command: sudo herd status caddy.

You should see something like so:

Status of caddy:
It is running since Wed Jul  8 18:06:04 2026 (41 days ago).
Main PID: 17241
Command: /run/privileged/bin/caddy run --config /etc/caddy/caddyfile --adapter caddyfile
It is enabled.
Provides: caddy
Requires: networking
Replacement pending (restart to upgrade).
Will be respawned.
Log file: /var/log/caddy.log

Let's verify it works:

curl localhost:8080 > Hello world

Final code

This is how our entire service looks like:

How to modify the Caddyfile

If you wish to change the Caddyfile, you can modify our %caddyfile and reconfigure the system. Alternatively, for quick debugging purpose, you can modify the file directly at /etc/caddy/caddyfile and restart the caddy server with sudo herd restart caddy.

Conclusion

I hope you enjoyed this article.

As a recap, we covered the following:

  • Guix services and the extensions architecture.
  • executing an arbitrary command when enabling a service (activation service type).
  • creating and managing users declaritively.
  • a (quick!) primer on g-exps.
  • elevating program priviliges.
  • running a service as a daemon in the background using Shepherd.

This gives us enough foundation to tackle many services.

As stated in the title, I am not an expert in Guix or Scheme. I am convinced there might be improvement to be made5; I stumbled my way through this creating this service.

If you have any comments, please kindly send them to contact@aloysberger.com.

In a follow up article, I will show how I started refactoring the <caddy-service> to make it configuratble.

Thank you, Aloys

LEFT OVER: Note: I mentioned earlier that services are composable. When a services is extendable, we can only reference one instance of that service. When the service isn't extendable, we can instantiate multiple times the same service.

Footnotes:

1

in the future, I want to write a small DSL to generate the caddy config file in a declarative matter.

2

I might have missed it?

3

unsure what is the convention here? I have seen all three

4

We won't do a deep dive into G-exps, there is enough to warrant its own article

5

such as using etc-service-type instead of the activation-service-type