Writing a Guix service from scratch, as a beginner

I am in the middle of migrating my machines from NixOS 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 on the official Guix channel.

At the time, I didn't know how to define my own custom services. I intend to use Guix for the foreseeable future, I want to build a deep understanding of it. Learning to create my own services, suited to my own needs, is an important part of mastering Guix.

Which brings us to the motivation behind this article: the time had come to create my own service.

For the proxy server, I enjoy using Caddy. Caddy isn't available as a service on Guix.1

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 for other newcomers.

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

This service involves setting up a configuration file for Caddy via Guix, creating a system user and configuring Shepherd to run the daemon.

It's a perfect first custom service: t is simple enough to ease into custom services, yet it includes a good overview of them.

Furthermore, 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.

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, without any extensive knowledge beyond reading the documentation.

Pre-requirements

The only true prerequisite is to have the Caddy package ready to go on your Guix instance, so we can build the service on top of it. 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 on the side while following this article.

I obviously assume that you have Guix installed and a working configuration. If not, you can find one here.

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

When I reference the Guix 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.

Our plan will be as follow:

  1. Define an empty service, and successfully "enable" it.
  2. Create the dameon user and group..
  3. Copy the Caddy configuration to the right location.
  4. Configure Shepherd to run the service as a daemon in the background.

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, 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, but that's because a service can have a wide variety of 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.

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 itself 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 services in the documentation. If you still don't quite understand the concept of services,, 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 the simplest service we can: a bare service that does nothing. The goal is just 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 enabled or "instantiated" (if we keep our analogy of classes) by the service procedure. The service procedure 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 defined in <my-service-type>

To enable the service, we add it to the list provided to the services procedure:

(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 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 notice 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 define our caddy-service-type. We 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 for now. 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 fields later, let's not worry about them for 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 the simplest service type: it only has a name and a description.

Let's enable 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 ....)    ;; Pre-existing services
                      (service caddy-service-type)) ;; <-- Add this line here
              %base-services)))       ;; %base-services are the default 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 -

You will notice that the Caddy service is present in your services. Nice. Now let's make that service do something useful.

2. Create the daemon user

The next step to improve our service is to instruct it 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 access to the Caddy configuration file.

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 it extra functionality. For example, in this case, I expected that extending the account-service-type would add the capability to create users and groups to our caddy-service-type.

However it is the opposite! You don't "add" capability to your service, you modify the target service (account-service-type here) to perform actions for your service. How the target service-type is modified is defined by the rules in its compose and extend fields. We'll go over them in more details further below.

For now, let's continue on our account creation to illustrate this concept. We want to create a user, caddy, and a group with the same name, caddy.

The account-service-type is defined as extendable. This means that it offers an interface for other services to create user accounts. The account-service-type doesn't need to know who is creating accounts and why; it only exposes a way to do so.

This is the beauty of this architecture: it makes the system completely declarative. When a service extends the account-service-type, then Guix will create the accounts when the service is enabled. If the service is not enabled, those accounts aren't created.

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

In order to instruct the account-service-type to create our caddy user when the caddy-service-type is enabled, we extend the account-service-type.

We now understand the concept of the extensions field. It 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 in practice.

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.

This is how the account-service-type is defined. We should be familiar with the <service-type> record structure.

(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.

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 composes multiple services extending this service-type.

    For example,if we have three services listing accounts-service-type as an extension, how do we handle each value? Do we only take the first? The last?

    Obviously, if three services are defining users by extending the account-service-type, we want to create all those users. Therefore, we want to concatenate the values provided by 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 services extend this service: 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 the compose field interact with the service's value itself.

It is, in a way, quite similar to compose

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 might 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 when defining the rules.

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 to create all the users and groups received.
  2. extend: append to the service value. so 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).

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, which is the root of all services. Under the hood, we end up with a collection of all our services in one, large, single structure. I assume that this is conglomerate of services is then handled by Guix when configuring the system.

Ok, 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 respectively.

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 we can add them 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 . This is account-service-type in our case. The second parameter to a service-extension is a procedure that takes 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" for the extension. In our case, a list of user and group account. You can imagine that using this second argument, we could create the user dynamically based on the value of our caddy-service-type if we wanted make our service configurable. That's how configurable services are structured.

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 syntactic sugar for (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.

> 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 learned to how to extend the account-service-type to create a user and a group for our service.
  • We have also learned 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 will be static. We will write the Caddyfile manually and provide it to our Caddy service. We won't be in charge of generating the Caddyfile2.

The Caddy service will simply be in charge of copying it to the right location.

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

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

Note that there is a service doing exactly this task, the etc-service-type. However I believe that using the more general activation-service-type for didactic purpose is a better choice. It's a widely used service; a lot of the service in the Guix repository rely on it.

The activation service is a service that is executed at activation of our service.

Interestingly enough, I couldn't find much about it in the documentation, 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 G-Expression or gexp

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

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

Shallow dive: G-exps

I won't go to deep into the concept of G-exps; there is a lot to cover and it merits its own article. 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. They aren't quite containers, but the concept is close enough for our understanding.

To ensure reproducibility, each package is built in its own "container", which only includes the required inputs. For this to happen, part of the code in package definition needs to be staged for later execution, inside that "container". G-expressions is how we staging code to be executed at build time.

The two environments of Guix are often referred as:

  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 inside the "container".

G-expressions are expressed with the #~ macro, such as #~( ;; this is a g-expression ;; ). Whenever you see the macro #~, it means that the code inside the bracket will be executed later, inside the build "container".

Inside a G-exp, we can evaluate some expressions using the #$ macro, or ungexp.

For example, let's have a look at this G-expression:

#~(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.

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 will be 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 read-only for the caddy user.

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.

This is how we can do so:

(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)) ;; we need some utilities, such as `mkdir-p
    #~(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 (notice the ungexp!).
        (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!"}

Great, we now have our user and our configuration file. We can test out our service by starting caddy manually.

sudo caddy -c /etc/caddy/caddyfile" # you need sudo here, we will resolve this in part 4.

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:

  1. creates the daemon user and group.
  2. generate the caddy configuration file
  3. copy it to the right location.

The last part is about making it run as a daemon 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).

We now have a working Caddy service. The last remaining part to this guide is to modify our service to instruct Shepherd to run it in the background.

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

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 users 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 necessary 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.

An important thing to note: when we make a program privileged, it is relocated outside of the store. The store cannot contain privileged for security concerns, you can read more about it 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, I won't go into too much detail. The Shepherd service documentation is very helpful and detailed, I highly recommend you to read it. In particular the page about the page about service constructor and destructors explains the role of make-forkexec-constructor and make-kill-desstructor, both doing a lot of the heavy lifting here.

Instead, I will comment the intentions on the extension itself:

(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

A few point worth mentioning:

  • 'user-processes is a requirement for most shepherd-service as mentioned in the 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 found 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 was a requirement for the logs. The service would otherwise it would complain. It is mentionned in the Caddy documentation when using systemctl.

Final code

This is how our entire service looks like:

(use-modules (gnu)
  (guix packages)
  (guix utils)
  (guix download)
  (guix licenses)
  (guix gexp)
  ;;; Redacted ... many modules
  )

(use-service-modules
  ;; Redacted... many modules
  desktop
  networking)

;; Our caddy package, as seen in a previous article
(define caddy
  (package
    (name "caddy")
    (version "2.11.4")
    (source (origin
              (method url-fetch)
              (uri (string-append
                     "https://github.com/caddyserver/caddy/releases/download/v"
                     version "/caddy_" version "_linux_amd64.tar.gz"))
              (sha256
                (base32 "1fbvxj6mifdqhwm5s1f8snr805k0anllzlri7cg9l61rgj8vyzsj"))))
    (build-system copy-build-system)
    (arguments
      (list #:install-plan #~'(("caddy" "bin/"))))
    (home-page "https://caddyserver.com")
    (synopsis "Web server with automatic HTTPS")
    (description "Caddy is an extensible web server with automatic TLS.")
    (license asl2.0)))

;; Our caddy user:group for the daemon
(define %caddy-accounts
  (list (user-account
          (name "caddy")
          (group "caddy")
          (system? #t)
          (comment "Caddy daemon user")
          (home-directory "/var/lib/caddy")
          (create-home-directory? #t)
          (shell (file-append shadow "/sbin/nologin")))
    (user-group
      (name "caddy")
      (system? #t))))

;; Our Caddyfile
(define %caddyfile
  (plain-file "Caddyfile"
    "aloysberger.com {
       root * /srv/http/blog
       file_server
     }
     :8080 {
        respond \"Hello, World!\"
     }")) ;; There is more to my Caddyfile, but yes, it serves this blog!

;; The activation to copy the Caddy file
(define (%caddy-activation _)
  (with-imported-modules '((guix build utils))
    #~(begin
        (use-modules (guix build utils))
        (mkdir-p "/etc/caddy")
        (copy-file #$%caddyfile "/etc/caddy/caddyfile")
        (chown "/etc/caddy/caddyfile"
          (passwd:uid (getpwnam "root"))
          (group:gid (getgrnam "caddy")))
        (chmod "/etc/caddy/caddyfile" 750))))

;; The Shepherd definition for our Caddy daemon
(define (%caddy-shepherd-service _)
  (list (shepherd-service
          (provision '(caddy))
          (documentation "Run the caddy daemon")
          (requirement '(networking))
          (start #~(make-forkexec-constructor
                     (list "/run/privileged/bin/caddy"
                       "run" "--config" "/etc/caddy/caddyfile"
                       "--adapter" "caddyfile")
                     #:user "caddy"
                     #:group "caddy"
                     #:log-file "/var/log/caddy.log"
                     #:environment-variables
                     '("HOME=/var/lib/caddy")))
          (stop #~(make-kill-destructor)))))

;; And finally, the entire service
(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)
        (service-extension shepherd-root-service-type %caddy-shepherd-service)))
    (default-value #t)))

(operating-system
  (locale "en_AU.utf8")
  (timezone "Australia/Brisbane")
  (keyboard-layout (keyboard-layout "au"))
  (host-name "[REDACTED]")
  (users (cons* (user-account
                  ;; [REDACTED - Users are defined here
                  %base-user-accounts))
    ;; Elevating Caddy's privilege to bind to port under 1024.
    (privileged-programs
      (append (list (privileged-program
                      (program (file-append caddy "/bin/caddy"))
                      (capabilities "cap_net_bind_service=ep")))
        %default-privileged-programs))

    (services
      (append (list (service ;; [REDACTED] Many services here: SSH, paperless, mail...
                      (service caddy-service-type) ;; <-- Our Caddy service!
                      %base-services))
        (bootloader (bootloader-configuration
                      (bootloader grub-bootloader)
                      (targets (list "[REDACTED]"))
                      (keyboard-layout keyboard-layout)))
        (swap-devices (list (swap-space
                              (target (uuid "[REDACTED")))))

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!

Nice. Our service is working and the process is running in the background, managed by Shepherd.

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

If you have read thus far, thank you!

As a summary, let's review what we have covered:

  • Guix services and the extensions architecture.
  • executing an arbitrary command when enabling a service (activation-service-type).
  • creating and managing users declaratively (user-account-service-type).
  • a (quick!) primer on G-expressions.
  • elevating program privileges with privileged-programs,
  • running a service as a daemon in the background using Shepherd (shepherd-service-type).

As stated in the title, I am not an expert in Guix or Scheme. I stumbled my way through this creating this service; and it probably show! In any case, I hope that it will help you getting start with custom services and that it clarified some concepts around services.

I am sure there are improvements to be made, such as using the etc-service-type instead of the activation-service-type. If you have any comments, please kindly send them to contact@aloysberger.com.

I hope you enjoyed this article.

Footnotes:

1

Not available yet, but I did see a PR a couple of months ago, so it might already be available.

2

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

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