Running your website using Guix System

Guix, Website, Guix System, Sysadmin,

Running a website is not always easy, especially with the complexity of setting up the web server itself.

The Goal

  1. Run Nginx on Guix System
  2. Run a website using Nginx on Guix System
  3. Run a CGit instance on the same server, making git repositories browsable
  4. Enable HTTPS for our sites

Running Nginx on Guix System

Running an instance of Nginx on Guix System is straightforward, just add it as a service-type.

(use-modules (gnu)
             (gnu system))
(use-service-modules web)

(operating-system
 ...
 (services (list (service nginx-service-type))))

Adding this one service starts Nginx with a default configuration that is provided by Guix that does nothing. The generated nginx.conf file only contains information about log files, PID files, the user and group to run the server as, information about MIME types, and some redirection paths. In its current state, Nginx will not listen on any ports and will not respond with anything.

The default Nginx configuration can be found with:

(use-modules (gnu)
             (gnu system)
             (gnu tests) ;; Provide simple-operating-system macro
             (gnu services web))

(simple-operating-system
 (service nginx-service-type))

Throughout this post, I use the simple-operating-system macro to reduce the amount of code you have to read. An example system is available at the end of this post that has a complete description of our web server.

Running a Website on Guix System

To run a website on Guix system with Nginx, you need to specify an nginx-server-configuration that adds a server block to the http block in the generated Nginx configuration.

(use-modules (gnu)
             (gnu tests) ;; For simple-operating-system macro
             (gnu services web))

(simple-operating-system
 (service nginx-service-type
          (nginx-configuration
           (server-blocks
            (list
             (nginx-server-configuration))))))

This simple configuration makes Nginx listen on both ports 80 and 443 (HTTP and HTTPS respectively), and serves content from /srv/http. You can find the default values in the manual. Now you just need to create an index.html file in /srv/http/, browse to your webserver, and you should see your new webpage!

This post does not attempt to cover every possible configuration option, their values, or their meanings. If you want to further customize your Nginx configuration with all the possible configuration options and their values, you should see the manual.

Make your Whole Site Reproducible

Reproducibility is one of Guix's main tenents, and this is a task that is particularly well-suited to the task, as serving a website amounts to just setting the webserver's webroot path in the configuration. For the rest of this post, assume we have a website we want to serve, called sample-website, which has already been packaged for Guix and builds properly.

(use-modules (gnu)
             (guix packages)
             (guix build-system copy)
             (gnu packages web))

(define sample-website
  (package
   (name "sample-website")
   (version "post")
   (source (package-source nginx)) ;; Use Nginx's built-in index.html as "website"
   (build-system copy-build-system)
   (arguments
    ;; NOTE: The double ' is to prevent evaluation and is required!
    '(#:install-plan
      '(("html/" "/"))))
   (home-page #f)
   (synopsis "Sample \"website\"")
   (description "Sample \"website\" for a blog post.")
   (license (package-license nginx))))

This simple website copies the contents of the html/ directory from Nginx's source tarball and copies it to our output directory. If you copy this package recipe to a file, you can build this sample website with:

guix build -f <path/to/sample-website.scm>

The most obvious way to get Nginx to use the build output for our sample-website is to pass it as the root for that server-configuration.

(use-modules (gnu)
             (gnu system)
             (gnu tests) ;; For simple-operating-system macro
             (gnu services web))

(simple-operating-system
 (service nginx-service-type
          (nginx-configuration
           (server-blocks
            (list
             (nginx-server-configuration
              (listen '("8080"))
              (server-name '("website.com"))
              (root sample-website)))))))

In fact, this will do exactly what you want! You can navigate to the server's IP address and port 8080, and you should see Nginx's default webpage. While this is all fine-and-dandy, though there are a few issues we will encounter later with this approach (skip to "Enabling HTTPS" to see the problem and its solution). For now, let's move onto doing something slightly more complicated.


Running CGit on the Same Server

CGit is a web server for Git that allows for browsing code online like one would with GitHub, GitLab, SourceHut, or any other online Git-browsing system. It is one of the Git servers used by the GNU project on Savannah, and by Guix. I personally think it looks nice and it complicates our Nginx setup a little bit more, so let's try setting it up.

Like before, Guix makes it trivial to add and set up cgit:

(use-modules (gnu)
             (gnu tests) ;; For simple-operating-system macro
             (gnu services networking)
             (gnu services cgit))

(simple-operating-system
 (service cgit-service-type
          (cgit-configuration
           (repository-directory "/srv/git/")))
 ;; We just need some kind of networking for fcgiwrap
 (service dhcp-client-service-type))

By default, cgit will create a new HTTP server block that listens on port 80. If you load your machine's website now, it should be a cgit page stating you have no repositories.

Our Website & CGit

Notice how the default ports for our website's nginx-server-configuration and cgit interfere with each other (they both listen on port 80). To solve this, we need to use redirection and named servers.

(use-modules (gnu)
             (gnu tests) ;; For simple-operating-system macro
             (gnu services networking)
             (gnu packages version-control) ;; For cgit package
             (gnu services cgit))

(simple-operating-system
 (service nginx-service-type
          (nginx-configuration
           (server-blocks
            (list
             (nginx-server-configuration
              (listen '("80"))
              (server-name '("website.com"))
              (root sample-website)
              (locations
               (list
                ;; Redirect website.com/cgit -> cgit.website.com
                (nginx-location-configuration
                 (uri "= /cgit")
                 (body '("return 308 $scheme://cgit.website.com ;")))
                ;; Redirect website.com/cgit/repo.git -> cgit.website.com/repo.git
                (nginx-location-configuration
                 (uri "~ /cgit(/.*)")
                 (body '("return 308 $scheme://cgit.website.com$1 ;"))))))))))
 (service cgit-service-type
          (cgit-configuration
           (repository-directory "/srv/git/")
           (nginx
            (list
             (nginx-server-configuration
              (listen '("80"))
              (server-name '("cgit.website.com"))
              (locations
               (list
                (nginx-location-configuration ;; So CSS & co. are found
                 (uri "~ ^/share/cgit/")
                 (body `(("root " ,cgit ";"))))
                (nginx-named-location-configuration
                 (name "cgit")
                 (body `(("fastcgi_param SCRIPT_FILENAME " ,cgit "/lib/cgit/cgit.cgi;")
                         "fastcgi_param PATH_INFO $uri;"
                         "fastcgi_param QUERY_STRING $args;"
                         "fastcgi_param HTTP_HOST $server_name;"
                         "fastcgi_pass 127.0.0.1:9000;"))))))))))
 ;; We just need some kind of networking for fcgiwrap
 (service dhcp-client-service-type))

This is a big block, but all of cgit-configuration's nginx field is a reproduction of what cgit's default Nginx configuration is set to. We must manually provide this entire body because we have no way to "inherit" the old configuration. The important parts are the locations in the nginx-configuration. These two nginx-location-configurations redirect requests made to your default website to your cgit site when website.com contains /cgit immediately afterwards.

Like the previous section, The cgit-service-type has too many configuration options to cover in this post. For further reading and customization, you should see the manual.

Now that we have configured Nginx to serve both our website and cgit, we should be good system/web admins and enable HTTPS for our sites.


Enabling HTTPS

HTTPS is a requirement for websites today, with any reasonable browser warning users that they are visiting an "Unprotected site" and the lock symbol missing. In the past, it was difficult to get an SSL/TLS certificate to authenticate the ownership of your servers. Today we are fortunate that the EFF created Certbot, a tool for easily creating and managing certificates. Certbot makes it easy to create new SSL/TLS certificates that are trusted by all the major browsers and keep the one you already have renewed and up-to-date.

On traditional Linux systems, enabling HTTPS is fairly straightforward with Certbot. In our case of using Nginx, you would just use the following command to get a new certificate, install it, and edit the nginx.conf file to automatically redirect all HTTP requests to your sites to HTTPS requests:

sudo certbot --nginx

But, Guix is not a traditional Linux system, so what does it do?

Certbot on Guix

As documented in Guix's manual, setting up Certbot to run for a website hosted at the URI website.com is as simple as adding the following lines.

(use-modules (gnu)
             (gnu tests) ;; For simple-operating-system macro
             (gnu services certbot))

(simple-operating-system
 (service certbot-service-type
          (certbot-configuration
           (certificates
            (list
             (certificate-configuration
              (name "website")
              (domains '("website.com"))))))))

The certbot-service-type does two things:

  1. It adds an mcron task that runs at a random time within the next twelve hours. This mcron task will call the /gnu/store/...-certbot-command Guile script which, unsurprisingly, runs Certbot. Once the command is runs correctly, /etc/certbot/live/<name> should exist as a directory, where <name> is the name you provided in the certificate-configuration record.
  2. Adds another HTTP server block to nginx.conf that redirects/upgrades all HTTP traffic to website.com to HTTPS.

Now that you have certificates, enabling HTTPS is quite simple. In the block below, I show a minimized version of the configuration. In particular, note the ssl-certificate, ssl-certificate-key and listen fields.

(use-modules (gnu)
             (gnu tests) ;; For simple-operating-system macro
             (gnu services networking)
             (gnu services cgit)
             (gnu services certbot))

(simple-operating-system
 (service certbot-service-type
          (certbot-configuration
           (certificates
            (list
             (certificate-configuration
              (name "website")
              (domains '("website.com")))
             (certificate-configuration
              (name "cgit")
              (domains '("cgit.website.com")))))))
 (service nginx-service-type
          (nginx-configuration
           (server-blocks
            (list
             (nginx-server-configuration
              (listen '("443 ssl"))
              (server-name '("website.com"))
              (ssl-certificate "/etc/letsencrypt/live/website/fullchain.pem")
              (ssl-certificate-key "/etc/letsencrypt/live/website/privkey.pem")
              (root sample-website))))))
 (service cgit-service-type
          (cgit-configuration
           (repository-directory "/srv/git/")
           (nginx
            (list
             (nginx-server-configuration
              (listen '("443 ssl"))
              (server-name '("cgit.website.com"))
              (ssl-certificate "/etc/letsencrypt/live/cgit/fullchain.pem")
              (ssl-certificate-key "/etc/letsencrypt/live/cgit/privkey.pem"))))))
 ;; We just need some kind of networking for fcgiwrap
 (service dhcp-client-service-type))

Now we only listen on port 443 for SSL/TLS traffic, as certbot-service-type handles redirecting/upgrading HTTP to HTTPS requests. We also specify the SSL/TLS certificates we generated with Certbot so that our website can be verified to be ours.

Guix-specific Certbot "Oddities"

On Guix:

Certbot's documentation about the --webroot flag means that some other webserver is the one that will response on port 80, in Guix's case, Nginx. Something to note, which is not stated anywhere, is that if the domains you pass in the certificate-configuration match the server-name of an nginx-server-configuration, the default-location of Certbot (which defaults to <server-webroot>/.well-known/) is relative to that server's webroot. Normally, this is not a problem if the webroot set to /var/www/<site>, as this is usually a readable and writable directory for the website, which is required for Certbot to handle the necessary challenge and responses.

Certbot, Nginx, and Guix Package Outputs

If you are like me, you want to have Guix build everything for you, including the website that your new Guix system will be serving. The quickest and most obvious way is to set the webroot of the server-configuration to the output of a Guix package that describes your website.

(use-modules (gnu)
             (gnu tests) ;; For simple-operating-system macro
             (gnu services web)
             (gnu services certbot))

(simple-operating-system
 (service nginx-service-type
          (nginx-configuration
           (server-blocks
            (list
             (nginx-server-configuration
              (listen '("443 ssl"))
              (server-name '("website.com"))
              ;; Set webroot to the output of sample-website
              (root sample-website)
              (ssl-certificate "/etc/letsencrypt/live/website/fullchain.pem")
              (ssl-certificate-key "/etc/letsencrypt/live/website/privkey.pem"))))))
 (service certbot-service-type
          (certbot-configuration
           (certificates
            (list
             (certificate-configuration
              (name "website")
              (domains '("website.com"))))))))

This sets webroot to a path in the store (/gnu/store/...-sample-website/), which contains your website. This works fine when serving the website, but is problematic when working with Certbot, which requires its .well-known directory inside of the webroot be writable. To fix this, you will need to set webroot to any readable and writable directory when generating the certificate, then change it back to the sample-website later.

This problem presents itself the same way for all Nginx server blocks that match the Certbot certificate-configuration, so we omit the cgit configuration for now.

Alternatively, we can create a new Guix service-type that provides a Shepherd service to install the website somewhere readable and writable. Such a Shepherd service can create the website's directory and copy the website's contents from the store to the output directory when started and remove the website when stopped. Combining Guix and Shepherd in this way lets us reproducibly put the built website in a world readable/writable environment. It also allows us to

(use-modules (gnu)
             (gnu tests) ;; For simple-operating-system macro
             (gnu services web)
             (gnu services certbot)
             (gnu services shepherd))

(define website-deploy-service
  (let ((website-dir "/srv/http/website.com"))
    (simple-service
     'website-deploy
     shepherd-root-service-type
     (list
      (shepherd-service
       (requirement '(file-systems))
       (provision '(website-deploy))
       (documentation "Copy website out of store to @file{/srv/http/website.com/}")
       (start #~(let ((website-in-store #$sample-website))
                  (lambda _
                    (mkdir-p #$website-dir)
                    ;; (guix build utils) already in scope for start by
                    ;; #:modules. See (guix) Shepherd Services
                    (copy-recursively website-in-store #$website-dir))))
       (stop #~(lambda _
                 (with-exception-handler (lambda (e) (pk 'caught e))
                   (lambda () (delete-file-recursively #$website-dir))
                   #:unwind? #t)
                 #f)))))))

(simple-operating-system
 website-deploy-service
 (service nginx-service-type
          (nginx-configuration
           (server-blocks
            (list
             (nginx-server-configuration
              (listen '("443 ssl"))
              (server-name '("website.com"))
              ;; Set webroot to the output of sample-website
              (root "/srv/http/website.com")
              (ssl-certificate "/etc/letsencrypt/live/website/fullchain.pem")
              (ssl-certificate-key "/etc/letsencrypt/live/website/privkey.pem"))))))
 (service certbot-service-type
          (certbot-configuration
           (certificates
            (list
             (certificate-configuration
              (name "website")
              (domains '("website.com"))))))))

Conclusion

Guix is an excellent tool for package management and system management! With less than 150 lines of code, we have created a definition for a computer, running Nginx and cgit over HTTPS thanks to Certbot. In addition, we are building our website with Guix, so not only is our system reproducible, but the packages the system's servers depend on are also reproducible!

A Buildable System

(use-modules (gnu)
             (guix packages)
             (guix build-system copy)
             (gnu packages web)
             ;; For nginx
             (gnu tests) ;; For %simple-os
             (gnu services web)
             ;; cgit
             (gnu packages version-control) ;; For cgit package
             (gnu services networking)
             (gnu services cgit)
             ;; certbot
             (gnu services certbot)
             ;; custom service-type
             (gnu services shepherd))

(define sample-website
  (package
   (name "sample-website")
   (version "post")
   (source (package-source nginx)) ;; Use Nginx's built-in index.html as "website"
   (build-system copy-build-system)
   (arguments
    ;; NOTE: The double ' is to prevent evaluation and is required!
    '(#:install-plan
      '(("html/" "/"))))
   (home-page #f)
   (synopsis "Sample \"website\"")
   (description "Sample \"website\" for a blog post.")
   (license (package-license nginx))))

(define website-deploy-service
  (let ((website-dir "/srv/http/website.com"))
    (simple-service
     'website-deploy
     shepherd-root-service-type
     (list
      (shepherd-service
       (requirement '(file-systems))
       (provision '(website-deploy))
       (documentation "Copy website out of store to @file{/srv/http/website.com/}")
       (start #~(let ((website-in-store #$sample-website))
                  (lambda _
                    (mkdir-p #$website-dir)
                    ;; (guix build utils) already in scope for start by
                    ;; #:modules. See (guix) Shepherd Services
                    (copy-recursively website-in-store #$website-dir))))
       (stop #~(lambda _
                 (with-exception-handler (lambda (e) (pk 'caught e))
                   (lambda () (delete-file-recursively #$website-dir))
                   #:unwind? #t)
                 #f)))))))

(operating-system
  (inherit %simple-os) ;; Get necessities
  (services
   (append
    (list
     website-deploy-service
     (service nginx-service-type
              (nginx-configuration
               (server-blocks
                (list
                 (nginx-server-configuration
                  (listen '("443 ssl"))
                  (server-name '("website.com"))
                  (root "/srv/http/website.com")
                  (ssl-certificate "/etc/letsencrypt/live/website/fullchain.pem")
                  (ssl-certificate-key "/etc/letsencrypt/live/website/privkey.pem")
                  (locations
                   (list
                    ;; Redirect website.com/cgit -> cgit.website.com
                    (nginx-location-configuration
                     (uri "= /cgit")
                     (body '("return 308 $scheme://cgit.website.com ;")))
                    ;; Redirect website.com/cgit/repo.git -> cgit.website.com/repo.git
                    (nginx-location-configuration
                     (uri "~ /cgit(/.*)")
                     (body '("return 308 $scheme://cgit.website.com$1 ;"))))))))))
     (service cgit-service-type
              (cgit-configuration
               (repository-directory "/srv/git/")
               (nginx
                (list
                 (nginx-server-configuration
                  (listen '("443 ssl"))
                  (server-name '("cgit.website.com"))
                  (ssl-certificate "/etc/letsencrypt/live/website/fullchain.pem")
                  (ssl-certificate-key "/etc/letsencrypt/live/website/privkey.pem")
                  (locations
                   (list
                    (nginx-location-configuration ;; So CSS & co. are found
                     (uri "~ ^/share/cgit/")
                     (body `(("root " ,cgit ";"))))
                    (nginx-named-location-configuration
                     (name "cgit")
                     (body `(("fastcgi_param SCRIPT_FILENAME " ,cgit "/lib/cgit/cgit.cgi;")
                             "fastcgi_param PATH_INFO $uri;"
                             "fastcgi_param QUERY_STRING $args;"
                             "fastcgi_param HTTP_HOST $server_name;"
                             "fastcgi_pass 127.0.0.1:9000;"))))))))))
     (service certbot-service-type
              (certbot-configuration
               (certificates
                (list
                 (certificate-configuration
                  (name "website")
                  (domains '("website.com")))
                 (certificate-configuration
                  (name "cgit")
                  (domains '("cgit.website.com")))))))
     ;; We just need some kind of networking for fcgiwrap
     (service dhcp-client-service-type))
    %base-services)))