Install Guix System on VPS with Full Root Access
Table of Contents
1. Contents
If you follow this writeup closely, I can assure you this in the end:
A perfectly running GuixSystem VPS with CaddyServer and GuileArtanis preconfigured as Shepherd services.
2. Warning
this is not a newbie guide, though it would work given you blindly trust me
well, the content below might be messy, but every single line of code has been real tested by me
make sure you don't miss or skip words if you decide to follow the steps
also, no warranty provided, I would definitely not pay for your loss just because your server is hacked, then you recalled that
"Hey, I left Flurando's ssh public key in the authorized_keys slot!"
Take risks yourself
By the way, all the source code in this file are liscensed under AGPLv3+,
but if you only pick a part, say the iptables block, which is copied from the Guix document, of course you only need to obey what the document claims
this file itself is liscensed under GFDLv1.3+
2.1. So I have to open source my whole vps if used your guide and code?
No, that is a misunderstanding.
The code included is not what provide service to others, instead it is for generating the whole system on your vps
You would have to provide code and right only if you try to provide the "quick configure Caddy+Artanis on Guix" as a service
So you don't need to worry at all if you just use it to deploy.
3. Requirement
- A vps running debian with ssh connection and full root access
- Familiar understanding of Guix, like how to configure new services, how to define/use Guile modules.
- A local machine with Guix installed
Note: binary install and system install are both ok
3.1. My VPS
here is the real vps I used for this tutorial
just to assure you that these steps are tested
Provider: Recknerd
IP: 192.3.150.11
STORAGE: 17G
MEMORY: 1G
OS: Debian 13 Trixie
the state of the machine is clean because just installed with control panel
3.2. My Local Machine
the commit I use when doing all this
GUIX: 8f5506763ec97a2f0cd0f569ba6998092457f8e8
NONGUIX: 0f68c1684169cbef8824fb246dfefa3e6832225b
got these commit on Nov 18 2015
just in case someone from the future found the need for time-machine
however, I actually used none from nonguix in this configuration
so including nonguix is just for example of how
Otherwise you might need a lot of time if some day you happen to require a package from nonguix on vps and don't want to build locally
4. Installation Steps
4.1. Compile Our Custom Guix Raw Image
First of all, we need a Guix Raw Image, linux-libre kernel is ok because we are using vm instead of bare metal
This is the config I used to generate one, file named "os.scm"
(define-module (os) #:use-module (gnu) #:use-module (gnu image) #:use-module (gnu system image) #:export (config config-legacy efi-raw-image mbr-raw-image)) (use-service-modules base networking messaging ssh admin linux guix security) (use-package-modules bootloaders ssh) (define %random-root-password (with-imported-modules '((guix build utils)) #~(begin (use-modules (guix build utils)) (invoke "source" #$(plain-file "random-password.sh" "\ password=$(cat /dev/urandom | tr -cd \"a-zA-Z0-9\" | head -c 128) passwd <<EOF $password $password EOF "))))) (define config (operating-system (bootloader (bootloader-configuration (bootloader grub-efi-bootloader) (targets (list "/boot/efi")) (terminal-outputs '(serial)) (terminal-inputs '(serial)) (serial-unit 0) (serial-speed 115200))) (host-name "cloud") (sudoers-file (plain-file "sudoers" "\ root ALL=(ALL) ALL %wheel ALL=(ALL) NOPASSWD: ALL ")) (file-systems (cons* (file-system (mount-point "/boot/efi") (type "vfat") (device "/dev/vda1")) (file-system (mount-point "/") (device "/dev/vda2") (type "ext4")) %base-file-systems)) (users (cons* (user-account (name "user") (comment "guix user") (group "users") (supplementary-groups (quote ("wheel"))) (home-directory "/home/user")) %base-user-accounts)) (packages %base-packages) (services (append (list (service iptables-service-type (iptables-configuration (ipv4-rules (plain-file "iptables.rules" "*filter :INPUT ACCEPT :FORWARD ACCEPT :OUTPUT ACCEPT -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -A INPUT -p tcp --dport 22 -j ACCEPT -A INPUT -j REJECT --reject-with icmp-port-unreachable COMMIT ")) (ipv6-rules (plain-file "ip6tables.rules" "*filter :INPUT ACCEPT :FORWARD ACCEPT :OUTPUT ACCEPT -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -A INPUT -p tcp --dport 22 -j ACCEPT -A INPUT -j REJECT --reject-with icmp6-port-unreachable COMMIT ")))) (simple-service 'random-root-password activation-service-type %random-root-password) (service dhcpcd-service-type) (service ntp-service-type) (service resize-file-system-service-type (resize-file-system-configuration (file-system (file-system (mount-point "/") (device "/dev/vda2") (type "ext4"))))) (service earlyoom-service-type) (service fail2ban-service-type (fail2ban-configuration (extra-jails (list (fail2ban-jail-configuration (name "sshd") (enabled? #t)))))) (service openssh-service-type (openssh-configuration (openssh openssh-sans-x) (permit-root-login #f) (password-authentication? #f) (authorized-keys `(("user" ,(plain-file "user.pub" "\ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJwTBvc5snhSMs3CwpPJHGgsQ6ch6oXudRiomP51C9Ja laptop "))))))) (modify-services %base-services (delete mingetty-service-type) (delete agetty-service-type) (delete console-font-service-type) (guix-service-type config => (guix-configuration (inherit config) (substitute-urls (append (list "https://substitutes.nonguix.org") %default-substitute-urls)) (authorized-keys (append (list (plain-file "non-guix.pub" "(public-key (ecc (curve Ed25519) (q #C1FD53E5D4CE971933EC50C9F307AE2171A2D3B52C804642A7A35F84F3A4EA98#)))") ; nonguix (plain-file "laptop.pub" "(public-key (ecc (curve Ed25519) (q #CEECE0D9C02222E40990337862D67238CDFC56ADCB6E56DD90F09EAC970FEF3C#)))") ; my laptop ) %default-authorized-guix-keys))))))))) (define config-legacy (operating-system (inherit config) (bootloader (bootloader-configuration (bootloader grub-bootloader) (targets (list "/dev/vda")) (keyboard-layout keyboard-layout))) (file-systems (cons* (file-system (mount-point "/") (device "/dev/vda1") (type "ext4")) %base-file-systems)) (services (modify-services (operating-system-user-services config) (resize-file-system-service-type configuration => (resize-file-system-configuration (inherit configuration) (file-system (file-system (mount-point "/") (device "/dev/vda1") (type "ext4"))))))))) (define efi-raw-image (image (inherit efi-disk-image) (operating-system config))) (define mbr-raw-image (image (inherit mbr-disk-image) (operating-system config-legacy))) config
as you can see, this includes my own ssh key and guix archive key, so remember to change them to yours
then run guix system image -L . -e "(@ (os) efi-raw-image)" or guix system image -L . -e "(@ (os) mbr-raw-image)" assuming you are in the same folder as "os.scm"
this should be determined by whether your vps have "/sys/firmware/efi", if it has, then first, not, second
get yourself some snake or cafe, wait for the command ends with /gnu/store/<a long string>-disk-image printed
now run cp /gnu/store/<a long string>-disk-image guix-vps
this "guix-vps" is the raw efi/mbr image we need
mine is about 2.4G for efi and 2.3G for mbr, very large, you may gzip it if you like, which takes some time here but save time later on the internet
4.2. Host the Image
in order to reinstall, we need the image be directly downloadable with http(s) link
so put it on some S3 Bucket or host one yourself
In my case, I use Cloudflared tunnel with a simple http server
open three virtual terminals
in one of them run sudo sysctl net.ipv4.ping_group_range="0 2147483647" close then the second <path to execuatble cloudflared binary> tunnel run --token <your cloudflared tunnel token>
of course you have to configure cloudflare service on the dash.cloudflare.com yourself, which I would not document here because I am not going to promote cloudflare
For the third… well I have a file named "serve", executable in $PATH
the content is as below
#!/bin/sh
set -m
# I know it is strange, but we actually have to send two SIGTERMs to close one artanis server process.
# redirect error to null to prevent kill complaining when we pass -h
cleanup() {
kill -15 %1 2>/dev/null
kill -15 %1 2>/dev/null
exit
}
trap cleanup SIGINT
trap cleanup SIGTERM
trap cleanup EXIT
guix shell --pure -e \"(@ (package guile-xyz artanis) easy-artanis)\" guile -- guile --no-auto-compile -e '(serve)' -s \"$0\" \"$@\" &
wait
cleanup
!#
(define-module (serve)
#:use-module (artanis artanis)
#:use-module (artanis config) ; need conf-set! to change the server core to guile instead of default
#:use-module (ice-9 getopt-long) ; for command line semi-automatic parsing support
#:use-module (ice-9 ftw) ; need scandir procedure in it
#:export (main))
(define *cwd* (getcwd))
(define *upload?* #f)
(define *upload-folder* "upload") ; this is the relative path to where this script is executed, change as needed
(define* (dir-render rc)
"render a dir using the provided index.html as template/index, if not provided, use a minimal provided one"
(let ((item-list (scandir (string-append *cwd* (rc-path rc)))))
(if (member "index.html" item-list)
(tpl->response (string-append *cwd* (rc-path rc) "/index.html")) ; yes, any embed scheme code would be evaluated! so don't use this script for folders you don't trust!
(tpl->response
`(html
(body
(ul
,@(map (lambda (str)
`(li (a (@ (href ,(string-append (rc-path rc) "/" str))) ,str))) ; no need to check for null list because "." and ".." would always be present
item-list))
,(when *upload?* (make-upload-sxml (string-append "." (rc-path rc))))))))))
;; this function definition has its origin on artanis online manual
(define (enable-upload)
(post "/upload" #:from-post `(store #:path ,*upload-folder*) ; the uploaded file would be stored in the *upload-folder* folder in the dir you execute this script
(lambda (rc)
(case (:from-post rc 'store)
((success) (response-emit "upload succeeded"))
((none) (response-emit "No uploaded files!"))
(else (response-emit "Impossible! Please report bug!"))))))
(define (make-upload-sxml path)
`(form (@ (action "/upload")
(method "post")
(enctype "multipart/form-data"))
(label (@ (for "fileInput")) "choose a file")
(br)
(input (@ (type "file")
(id "fileInput")
(name "file")
(required)))
(br)
(button (@ (type "submit")) ,*upload-folder*)))
(define (main args)
;; handle the command line
(let* ((option-spec '((help (single-char #\h) (value #f))
(enable-upload (single-char #\u) (value #f))
(expose (single-char #\e) (value #f))
(port (single-char #\p) (value #t))))
(options (getopt-long args option-spec)))
(let ((h (option-ref options 'help #f))
(u (option-ref options 'enable-upload #f))
(e (option-ref options 'expose #f))
(p (option-ref options 'port #f)))
(when u (set! *upload?* u))
(if h
(format #t "\
This is a small script of serving static files
written in Guile Artanis
Usage:
--help -h print this message and exit
--expose -e operate on 0.0.0.0 instead of 127.0.0.1
--enable-upload -u enable file uploading to the relevant ~s folder
" *upload-folder*)
;; prepare the service
(begin
(init-server #:i18n? #f)
(conf-set! '(server engine) 'guile)
(when e (conf-set! '(host addr) "0.0.0.0"))
(when p (conf-set! '(host port) (string->number p))) ; p passed through argument line is a string, but host.port expects exact integar
(get "/.*" (lambda (rc)
(let ((path (string-append "." (rc-path rc)))) ; well, walkaround, removing the preceding #\/ would also work (hope so but honestly not tested)
(if (file-exists? path)
(let ((type (stat:type (stat path))))
(case type
[(regular)
(static-page-emitter rc #:dir (string-append *cwd* "/."))] ; chill, man, walkaround for artanis which insists on using pub folder
[(directory) ; NOT_PLANNED since dir and normal file can't share name on linux, actually linux views dir as file of type directory and normal file as regular file, I don't feel the need to tackle with the situation described below, and of course, don't know how. FIXME when directory and file shares the same name, we would not be able to enter the directory! basic idea is to change dir-render to render different url for file and directory instead of blindly list them out, and here we have to handle modified urls explicitly.
(dir-render rc)]))))))
(get "/" dir-render) ; we need this because if this route goes to the previous handler, (rc-path rc) would return *unspecified*, breaking the call to string-append which expects strings as input only, thus requiring additional guard condition checking, which is easily preventable with this.
(when *upload?* (enable-upload)) ; doesn't use variable u here to make it easier for me to wrap the command line logic in a seperate procedure later, increased modularity.
;; at last, ignite the server!
(run))))))
;; Local Variables:
;; mode: scheme
;; End:
so run serve in the same directory of "guix-vps" file, we can download it with "https://your.domain.here/guix-vps"
as long as your cloudflare tunnel is configured to proxy "http://localhost:3000"
The "easy-artanis" variation is a patched version of artanis for local scripting usage, patch file available on "https://codeberg.org/rw-flurando/Dotfile"
Note that the artanis version used is old here, so not recommended for production usage – yah, I still have no motivation to adapt my patches to the newer version
You don't really need all this chore, just python3 -m http.server works well with a port on "8000" instead
4.3. Connect to VPS
Somehow the Racknerd freshly installed Debian has only rsa and dsa host key
We have to change that
Login the VNC from control panel with root and given password
run apt update && apt upgrade, ssh-keygen -A and systemctl restart sshd in order
then run ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub to view the sha256 fingerprint
note it down
then close the VNC
from our own terminal or console, run ssh root@192.3.150.11 or torsocks ssh root@192.3.150.11 if you like
it would warn you the unknown fingerprint
check if the shown remains the same with what we noted down earlier
if is, type yes, enter, type in the given password
4.4. Fetch the reinstall script
This is easy, just run curl -O https://raw.githubusercontent.com/Flurando/reinstall/safe/reinstall.sh || wget -O ${_##*/} $_
if you don't have curl or wget, install them first
now there should be a "reinstall.sh" file
4.5. Run the reinstall script
run bash reinstall.sh dd --img "https://your.domain.here/guix-vps" --ssh-key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJwTBvc5snhSMs3CwpPJHGgsQ6ch6oXudRiomP51C9Ja laptop"
even though the ssh key here is only used for intermediate alpine, you might not want to use mine locking yourself out, so change it
it would go through some check and prepare to reboot into alpine live system
just run reboot if everything feels ok
you would be fine if now the artanis console only receives two octat-stream requests, one terminated by peer, the other going on
I suggest you to grab some food or drinks, until the second request ends naturally
but if you are impatient, of course you can clear .ssh/known_hosts and ssh root@192.3.150.11 for a look
the required command tail -fn+1 /reinstall.log would be printed on login
actually I failed to use efi image here, then I switched back to mbr image, and by the way found that there is no "/sys/firmware/efi" in the vps…
after using the mbr image on my second attempt, the process finished nicely
for the following tutorial, I would go as mbr image
4.6. Verify Installation
clear .ssh/known_hosts then run ssh user@192.3.150.11
if you successfully login with a prompt beginning with user@cloud then we are in Guix now!
run lsblk -f to see whether the file system has been resized
then assuming you are also using the mbr image with only one partition, "/dev/vda1"
for a manual resize, run sudo fdisk /dev/vda and type e enter w enter to resize "/dev/vda1"
then use sudo resize2fs /dev/vda1 to resize the file system on it
now see lsblk -f whether the storage available is correct
before we exit, use sudo cat /etc/ssh/ssh_host_ed25519_key.pub
for example my output is ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJEVQ2my9/HwE05U366qh+8/1aZuwWOjF204L8q890+4 root@(none)
note it down because we need this in guix deploy later
if things look fine, we can close the connection now
4.7. Prepare Deployment Script
create a new "deploy.scm" file
put the content below in it
I commented the docker part out because the Recknerd machine does not support docker at all
no idea if it is because some settings in their qemu or here requires vmx in "/proc/cpuinfo"
if cat /proc/cpuinfo | grep vmx and cat /proc/cpuinfo | grep svm both return nothing for you
it is likely that your machine could not run docker as well
note that if you use docker, specifically the elogind
you have to restart the server, otherwise ssh connection would be closed at once the connection is established
so unfortunately here the docker part would have to be jumped
you can reference to the document of Guix and the comment-outed part anyway, if your vps supports it
#!/bin/sh exec guix deploy -L . "$0" "$@" !# (define-module (deploy) #:use-module (gnu system) #:use-module (gnu machine) #:use-module (gnu machine ssh) #:use-module (gnu packages) #:use-module (gnu services) #:use-module (gnu services admin) ; resize-file-system, unattended-upgrade ;;#:use-module (gnu services containers) ; oci ;;#:use-module (gnu services desktop) ; elogind ;;#:use-module (gnu services docker) ; docker, containerd #:use-module (gnu services linux) ; fstrim #:use-module (gnu services mcron) ; mcron #:use-module (guix gexp) #:use-module ((os) #:select (config config-legacy) #:prefix base-) #:export (machines)) (define real-applied-config (operating-system (inherit base-config-legacy) (services (cons* (service unattended-upgrade-service-type (unattended-upgrade-configuration (channels #~(cons* (channel (name 'nonguix) (url "https://gitlab.com/nonguix/nonguix") (introduction (make-channel-introduction "897c1a470da759236cc11798f4e0a5f7d4d59fbc" (openpgp-fingerprint "2A39 3FFF 68F4 EF7A 3D29 12AF 6F51 20A0 22FB B2D5")))) %default-channels)) (reboot? #t))) (simple-service 'my-cron-jobs mcron-service-type (list #~(job "5 0 * * *" "guix gc -F 1G"))) (service fstrim-service-type) ;;(service docker-service-type) ;;(service containerd-service-type) ;;(service elogind-service-type) ;;(simple-service 'oci-setup-network ;;oci-service-type ;;(oci-extension ;;(networks (list (oci-network-configuration ;;(name "static") ;;(gateway "172.18.0.1") ;;(subnet "172.18.0.0/16") ;;(ip-range "172.18.0.0/24")))))) (modify-services (operating-system-user-services base-config-legacy) (delete resize-file-system-service-type)))))) (define machines (list (machine (operating-system real-applied-config) (environment managed-host-environment-type) (configuration (machine-ssh-configuration (host-name "192.3.150.11") (build-locally? #t) (authorize? #t) (system "x86_64-linux") (user "user") (host-key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJEVQ2my9/HwE05U366qh+8/1aZuwWOjF204L8q890+4") (port 22)))))) machines ;; Local Variables: ;; mode: scheme ;; End:
see the host-key entry in the machine list?
Yes, it is exactly the host public key
now use chmod 700 deploy.scm to make sure you can execute this file… after adapting of course
then try ./deploy.scm
see guix deploy: successfully deployed cloud in the end? Good.
we can ssh into it and run guix shell neofetch -- neofetch
mine prints as below, with color blocks stripped
.. `. user@cloud
`--..```..` `..```..--` ----------
.-:///-:::. `-:::///:-. OS: Guix System x86_64
````.:::` `:::.```` Host: KVM RHEL 7.6.0 PC (i440FX + PIIX, 1996)
-//:` -::- Kernel: 6.17.7-gnu
://: -::- Uptime: 2 hours, 40 mins
`///- .:::` Packages: 47 (guix-system)
-+++-:::. Shell: bash 5.2.37
:+/:::- Terminal: /dev/pts/0
`-....` CPU: Intel Xeon E5-2680 v2 (1) @ 2.799GHz
GPU: 00:02.0 Cirrus Logic GD 5446
Memory: 251MiB / 965MiB
5. I Have Services to Run
What? That's true, we switched from old good debian to guix, but we don't want a vps to be a toy box only
It should run some service, like a web server
When docker is ok, it is easy if you would like to refer to Guix document in oci-service-type and the corresponding docker compose or docker run
But now docker is unavailable, what could we do?
Well, I would not assume you happen to use service that is already packaged in Guix
instead, let me show you how to easily wrap one up your self, with prebuilt binary
however, note that since Guix does not follow FHS, you might face huge trouble if the binary assumes some path
and if you happen, which is very likely, not to be so good at strace/patchelf/ldd stuff, it simply means no way to run
The only exception is statically compiled binary, like those written in go
5.1. Caddy the Web Server
How to install binary file reliably on Guix? There is a service called activation, which is run at boot time to set up system folders
so we shall extend that to download a caddy executable to "/usr/bin" folder
now create a folder called "services", inside which make a file called "caddy.scm"
for Guix's naming convention, it should be called "web", but for the sake of easiness, I would just call it "caddy"
the caddy service is as below
(define-module (services caddy) #:use-module (gnu services shepherd) #:use-module (gnu services) #:use-module (gnu packages admin) ; shadow #:use-module (gnu system shadow) ; account-service-type #:use-module (guix gexp) #:use-module (guix records) ; define-record-type #:use-module (ice-9 match) ; match-lambda #:export (caddy-service-type caddy-configuration caddy-configuration?)) (define-record-type* <caddy-configuration> caddy-configuration make-caddy-configuration caddy-configuration? (binary-url caddy-configuration-binary-url ;string (default "https://caddyserver.com/api/download?os=linux&arch=amd64")) (extra-options caddy-configuration-extra-options ; list of strings (default '()))) (define caddy-activation (match-lambda (($ <caddy-configuration> url _) #~(begin (use-modules (guix build utils)) (mkdir-p "/var/www") (mkdir-p "/var/www/html") (invoke "chown" "caddy:caddy" "/var/www/html") (chmod "/var/www/html" #o777) (unless (file-exists? "/var/www/html/index.html") (with-output-to-file "/var/www/html/index.html" (lambda () (display "\ <!DOCTYPE html> <html> <head> <title>Success!</title> <meta charset=\"UTF-8\"> <style type=\"text/css\"> body { text-align: center; background-color: black; color: orange; } </style> </head> <body> <h1>Caddy service is on</h1> <p>Put your file in /var/www/html to start hosting</p> </body> </html> "))) (chmod "/var/www/html/index.html" #o666)) (mkdir-p "/etc/caddy") (invoke "chown" "-R" "caddy:caddy" "/etc/caddy") (chmod "/etc/caddy" #o775) (unless (file-exists? "/etc/caddy/Caddyfile") (with-output-to-file "/etc/caddy/Caddyfile" (lambda () (display "\ http://127.0.0.1:80 { root * /var/www/html file_server } "))) (invoke "chown" "caddy:caddy" "/etc/caddy/Caddyfile")) (unless (file-exists? "/usr/bin/caddy") (invoke "/run/current-system/profile/bin/wget" #$url "-O" "/usr/bin/caddy") (chmod "/usr/bin/caddy" #o550) (invoke "guix" "shell" "libcap" "--" "setcap" "'cap_net_bind_service=+ep'" "/usr/bin/caddy") ))))) (define (caddy-accounts _) "Return the user accounts and user groups." (list (user-group (name "caddy") (system? #f)) (user-account (name "caddy") (system? #t) (group "caddy") (supplementary-groups '()) (comment "caddy server runner") (home-directory "/home/caddy") (shell (file-append shadow "/sbin/nologin"))))) (define caddy-shepherd-service (match-lambda (($ <caddy-configuration> _ extra-options) (list (shepherd-service (provision '(caddy)) (documentation "Run caddy server.") (requirement '(user-processes networking)) (start #~(make-forkexec-constructor (list "/usr/bin/caddy" "run" "--config" "/etc/caddy/Caddyfile" #$@extra-options) #:directory "/home/caddy" #:environment-variables '("HOME=/home/caddy") #:user "caddy" #:group "caddy")) (auto-start? #t) (respawn? #t) (stop #~(make-kill-destructor))))))) (define caddy-service-type (service-type (name 'caddy) (extensions (list (service-extension shepherd-root-service-type caddy-shepherd-service) (service-extension account-service-type caddy-accounts) (service-extension activation-service-type caddy-activation))) (description "Run caddy server.") (default-value (caddy-configuration))))
Then in our "deploy.scm"
add caddy service and modified iptable rule correspondingly
the file may look like this
#!/bin/sh exec guix deploy -L . "$0" "$@" !# (define-module (deploy) #:use-module (gnu system) #:use-module (gnu machine) #:use-module (gnu machine ssh) #:use-module (gnu packages) #:use-module (gnu services) #:use-module (gnu services admin) ; resize-file-system, unattended-upgrade #:use-module (gnu services linux) ; fstrim #:use-module (gnu services mcron) ; mcron #:use-module (gnu services networking) ; iptable #:use-module (guix gexp) #:use-module ((system os) #:select (config-legacy) #:prefix base-) #:use-module (services caddy) #:export (machines)) (define real-applied-config (operating-system (inherit base-config-legacy) (services (cons* (service unattended-upgrade-service-type (unattended-upgrade-configuration (channels #~(cons* (channel (name 'nonguix) (url "https://gitlab.com/nonguix/nonguix") (introduction (make-channel-introduction "897c1a470da759236cc11798f4e0a5f7d4d59fbc" (openpgp-fingerprint "2A39 3FFF 68F4 EF7A 3D29 12AF 6F51 20A0 22FB B2D5")))) %default-channels)) (reboot? #t))) (simple-service 'my-cron-jobs mcron-service-type (list #~(job "5 0 * * *" "guix gc -F 1G"))) (service fstrim-service-type) (service caddy-service-type (caddy-configuration (binary-url "https://caddyserver.com/api/download?os=linux&arch=amd64&p=github.com%2Fcaddy-dns%2Fcloudflare"))) (modify-services (operating-system-user-services base-config-legacy) (delete resize-file-system-service-type) (iptables-service-type config => (iptables-configuration (ipv4-rules (plain-file "iptables.rules" "*filter :INPUT ACCEPT :FORWARD ACCEPT :OUTPUT ACCEPT -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -A INPUT -p tcp --dport 22 -j ACCEPT -A INPUT -p tcp --dport 80 -j ACCEPT -A INPUT -p tcp --dport 443 -j ACCEPT -A INPUT -j REJECT --reject-with icmp-port-unreachable COMMIT ")) (ipv6-rules (plain-file "ip6tables.rules" "*filter :INPUT ACCEPT :FORWARD ACCEPT :OUTPUT ACCEPT -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -A INPUT -p tcp --dport 22 -j ACCEPT -A INPUT -p tcp --dport 80 -j ACCEPT -A INPUT -p tcp --dport 443 -j ACCEPT -A INPUT -j REJECT --reject-with icmp6-port-unreachable COMMIT "))))))))) (define machines (list (machine (operating-system real-applied-config) (environment managed-host-environment-type) (configuration (machine-ssh-configuration (host-name "192.3.150.11") (build-locally? #t) (authorize? #t) (system "x86_64-linux") (user "user") (host-key "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICvL95xt5qYD73TsSPt6nPUp4YVTQWxSB9xBRXMjMiFf") (port 22)))))) machines ;; Local Variables: ;; mode: scheme ;; End:
I believe you can find the difference yourself, just caddy and modified iptables configuration
by default, I use guix shell libcap -- sudo setcap 'cap_net_bind_service=+ep' /usr/bin/caddy to make sure caddy can open low port
so that we can run caddy with a dedicated user other than root
run ssh -L 8000:127.0.0.1:80 user@192.3.150.11 -N then use curl http://127.0.0.1:8000 or just visit in browser to check
if you are greeted with my handcrafted welcome page, black background, orange foreground, everything is ok
after that, just use your familiar way to manage the /etc/caddy/Caddyfile and /var/www/html/* on the vps
remember we don't do systemctl restart/reload caddy here, just sudo herd restart caddy
if you would like to support sudo herd reload caddy, you need a custom shepherd-action, write it yourself or wait for others
5.2. Artanis the Dynamic Backend
there is not much to talk if situation remains the same, like you use some statically compiled backend
or python, which just python3 -m venv <your chosen folder> and use some shepherd service extention would be enough
For artanis, which is available in guix, situation would only be simpler
Well, in a sense, still requires time though
create a file called "artanis.scm" in services folder
the content is as below
(define-module (services artanis) #:use-module (gnu services shepherd) #:use-module (gnu services) #:use-module (gnu packages admin) ; shadow #:use-module (gnu packages guile-xyz) ; artanis #:use-module (gnu system shadow) ; account-service-type #:use-module (guix gexp) #:use-module (guix records) ; define-record-type #:use-module (ice-9 match) ; match-lambda #:export (artanis-service-type artanis-configuration artanis-configuration?)) (define-record-type* <artanis-configuration> artanis-configuration make-artanis-configuration artanis-configuration? (package artanis-configuration-package ; file like (default artanis)) (work-directory artanis-configuration-work-directory ; string (default "backend"))) (define artanis-activation (match-lambda (($ <artanis-configuration> package work-directory) #~(begin (use-modules (guix build utils)) (define workdir (string-append "/home/artanis/" #$work-directory)) (unless (directory-exists? workdir) (invoke (string-append #$package "/bin/art") "create" workdir) (invoke "chown" "-R" "artanis:artanis" workdir)))))) (define (artanis-accounts _) "Return the user accounts and user groups." (list (user-group (name "artanis") (system? #f)) (user-account (name "artanis") (system? #t) (group "artanis") (supplementary-groups '()) (comment "artanis server runner") (home-directory "/home/artanis") (shell (file-append shadow "/sbin/nologin"))))) (define artanis-shepherd-service (match-lambda (($ <artanis-configuration> package work-directory) (list (shepherd-service (provision '(artanis)) (documentation "Run artanis server.") (requirement '(user-processes networking)) (start #~(make-forkexec-constructor (list (string-append #$package "/bin/art") "work") #:directory (string-append "/home/artanis/" #$work-directory) #:user "artanis" #:group "artanis")) (auto-start? #t) (respawn? #t) (stop #~(make-kill-destructor))))))) (define artanis-service-type (service-type (name 'artanis) (extensions (list (service-extension shepherd-root-service-type artanis-shepherd-service) (service-extension account-service-type artanis-accounts) (service-extension profile-service-type (lambda (config) (list (artanis-configuration-package config)))) (service-extension activation-service-type artanis-activation))) (description "Run artanis server.") (default-value (artanis-configuration))))
then simply add #:use-module (services artanis) in the define-module block
and (service artanis-service-type) in the services block
well, you also have to tweak the iptable for artanis to listen on http://127.0.0.1:3000
to do this add a line -A INPUT -i lo -p tcp --dport 3000 -j ACCEPT before the last line inside ipv4 configuration
run ./deploy.scm followed with ./deploy.scm -x -- sudo reboot
Wait a while then try ssh user@192.3.150.11, use sudo herd status to check if all services successfully runs
if guix shell curl -- curl http://127.0.0.1:3000 prints the default message, we are fine
now we have deployed a local only caddy daemon and a local only artanis daemon
the neofetch returns
.. `. user@cloud
`--..```..` `..```..--` ----------
.-:///-:::. `-:::///:-. OS: Guix System x86_64
````.:::` `:::.```` Host: KVM RHEL 7.6.0 PC (i440FX + PIIX, 1996)
-//:` -::- Kernel: 6.17.7-gnu
://: -::- Uptime: 3 hours, 44 mins
`///- .:::` Packages: 48 (guix-system)
-+++-:::. Shell: bash 5.2.37
:+/:::- Terminal: /dev/pts/0
`-....` CPU: Intel Xeon E5-2680 v2 (1) @ 2.799GHz
GPU: 00:02.0 Cirrus Logic GD 5446
Memory: 218MiB / 965MiB
Nothing real public is turned on, but everything is running seamlessly, right?
It is true that I only showed you how to run CaddyServer and GuileArtanis, but I hope this shall be enough for you to start your own, creative and useful services
6. Onto the Internet
Assume you have got a website already, mine is generated with org publish
however, in this case, using rsync would be better than scp obviously, which requires rsync to be installed on server as well
so we shall add rsync to operating system packages block
use (packages (cons* rsync (operating-system-packages base-config/base-config-legacy))) to add rsync then ./deploy.scm again
Create a "Makefile" with following content
.PHONY: html caddyfile RSYNC = rsync -chavzP --delete-after --delete-excluded SSH = ssh $(REMOTE) REMOTE = user@192.3.150.11 html: $(RSYNC) html/* $(REMOTE):/var/www/html/ --exclude={".*","*~","*#",".#*"} caddyfile: scp Caddyfile $(REMOTE):/etc/caddy/Caddyfile
now running make html and make caddyfile could update the website and caddy config respectively
given you have a folder called html and a Caddyfile present, soft links are ok too
a simple Caddyfile could look like this
your.domain.name {
tls {
dns cloudflare your-cloudflare-dns-api-token
}
root * /var/www/html
file_server
reverse_proxy /api/* localhost:3000
}
run make caddyfile then ./deploy.scm -x -- sudo herd restart caddy to use the new configuration
of course the restart caddy can be made in Makefile, and when we only interact with one server, this would be better
so now I update the Makefile
.PHONY: html caddyfile RSYNC = rsync -chavzP --delete-after --delete-excluded SSH = ssh $(REMOTE) REMOTE = user@192.3.150.11 html: $(RSYNC) html/* $(REMOTE):/var/www/html/ --exclude={".*","*~","*#",".#*"} caddyfile: scp Caddyfile $(REMOTE):/etc/caddy/Caddyfile $(SSH) "sudo herd restart caddy"
I know that the user and group would not be caddy:caddy but user:users
which is not the best practice, however I just failed to do so
and in the meanwhile I don't think banning others to read the static website content and Caddyfile is really critical
Also, if you only changed the /var/www/html content, there is no need to restart Caddy or even a reload
however you might need to refresh cache – I got really annoyed by Cloudflare serving the old content stubbornly until I mannually request a clear cache
After testing in browser, my website is on, good
Since I don't have a long term domain, the site location would not be included here, though
Now we still have Artanis to be tested, we need Makefile again
Oops, we don't have access to artanis home folder at all
add ourself to caddy and artanis group, then put --chown=user:artanis in lines rsyncing artanis file
updated Makefile is as below
.PHONY: html caddyfile artanis artanis-conf artanis-restart RSYNC = rsync -chrvzP --exclude={".*","*~","*\#",".\#*"} --delete-after --delete-excluded SSH = ssh $(REMOTE) REMOTE = user@192.3.150.11 html: $(RSYNC) html/* $(REMOTE):/var/www/html/ caddyfile: scp Caddyfile $(REMOTE):/etc/caddy/Caddyfile $(SSH) "sudo herd restart caddy" artanis: $(RSYNC) --exclude=conf/artanis.conf backend/* $(REMOTE):/home/artanis/backend/ artanis-conf: scp artanis.conf $(REMOTE):/home/artanis/backend/conf/artanis.conf artanis-restart: $(SSH) "sudo chown -R artanis:artanis /home/artanis && sudo herd restart artanis"
and make artanis home writable by artanis group, change the activation service in artanis-service-type
to this
(define artanis-activation (match-lambda (($ <artanis-configuration> package work-directory) #~(begin (use-modules (guix build utils)) (define workdir (string-append "/home/artanis/" #$work-directory)) (unless (directory-exists? workdir) (invoke (string-append #$package "/bin/art") "create" workdir) (invoke "chmod" "-R" "770" "/home/artanis") (invoke "chown" "-R" "artanis:artanis" workdir))))))
see the "770" set for /home/artanis ? Good
still not working, at last I add a fix script to chmod and chown for /home/artanis every change, in the makefile
at last my Makefile look like this
.PHONY: html caddyfile artanis-content artanis-conf artanis-fix artanis-restart artanis-refresh artanis-refresh-config RSYNC = rsync -chrvzP --exclude={".*","*~","*\#",".\#*"} --delete-after --delete-excluded SSH = ssh $(REMOTE) REMOTE = user@192.3.150.11 html: $(RSYNC) --chown=user:caddy html/* $(REMOTE):/var/www/html/ caddyfile: scp Caddyfile $(REMOTE):/etc/caddy/Caddyfile $(SSH) "sudo herd restart caddy" artanis-content: $(RSYNC) --exclude=conf/artanis.conf backend/* $(REMOTE):/home/artanis/backend/ artanis-conf: scp artanis.conf $(REMOTE):/home/artanis/backend/conf/artanis.conf artanis-fix: $(SSH) "sudo chown -R artanis:artanis /home/artanis/ && sudo chmod -R 770 /home/artanis/" artanis-restart: artanis-fix $(SSH) "sudo herd enable artanis && sudo herd restart artanis" artanis-refresh: artanis-content artanis-restart artanis-refresh-config: artanis-conf artanis-restart
quite messy, but it works, with the api v3 file in artanis folder, cool
for anyone wanna test, the app/api/v3.scm content is as below
;; RESTful API v3 definition of backend ;; Please add your license header here. ;; This file is generated automatically by GNU Artanis. (define-restful-api v3) ; DO NOT REMOVE THIS LINE!!! (api-define info (method get) (options #:mime 'sxml) (lambda (rc) (:mime rc `((notice "nothing is configured here yet, but it works!")))))
and the corrsponding artanis.conf in this folder
## -*- indent-tabs-mode:nil; coding: utf-8 -*- ## Copyright (C) 2025 ## "Mu Lei" known as "NalaGinrut" <mulei@gnu.org> ## Artanis is free software: you can redistribute it and/or modify ## it under the terms of the GNU General Public License as published by ## the Free Software Foundation, either version 3 of the License, or ## (at your option) any later version. ## Artanis is distributed in the hope that it will be useful, ## but WITHOUT ANY WARRANTY; without even the implied warranty of ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ## GNU General Public License for more details. ## You should have received a copy of the GNU General Public License ## along with this program. If not, see <http://www.gnu.org/licenses/>. ## --------------------------------------------------------------------- ## The skeleton of config file, you may modify it on your own purpose. ## DON'T TYPE `;' AT THE END OF LINE!!! ## --------------------------------------------------------------------- db.enable = false db.dbd = mysql db.proto = tcp db.addr = 127.0.0.1:3306 db.socketfile = false db.username = root db.passwd = db.name = privaroom db.engine = InnoDB db.poolsize = 64 db.pool = increase db.encodeparams = false db.lpc = false server.info = GNU Artanis-1.3.0 server.nginx = true server.charset = utf-8 server.syspage.path = /etc/artanis/pages server.backlog = 128 server.wqlen = 64 server.trigger = edge server.engine = ragnarok server.timeout = 60 server.polltimeout = 500 server.bufsize = 12288 server.multi = false server.workers = 1 server.websocket = false server.pub = pub server.sendfile = false server.mmapped = false server.allowedmethods = GET,POST server.jsmanifest = pub websocket.maxpayload = 18446744073709551615 websocket.minpayload = 1 websocket.fragment = 4096 websocket.maxsize = 1024 websocket.timeout = 64 host.name = false host.addr = 127.0.0.1 host.port = 3000 host.family = ipv4 host.detectpath = false session.path = session session.hijackcheck = false session.backend = simple session.i18n = json upload.types = jpg,png,gif upload.path = upload upload.size = 5242880 mail.sender = /usr/bin/msmtp cache.maxage = 3600 debug.enable = false debug.monitor = cookie.expires = 3600
well, you can test it out yourself, visiting your.domain.here, then your.domain.here/api/v3/info
the fix that change owner and permission is quite ugly, but at least things are really working now!
7. Neofetch in the End
let me run neofetch one last time for this writeup
.. `. user@cloud
`--..```..` `..```..--` ----------
.-:///-:::. `-:::///:-. OS: Guix System x86_64
````.:::` `:::.```` Host: KVM RHEL 7.6.0 PC (i440FX + PIIX, 1996)
-//:` -::- Kernel: 6.17.7-gnu
://: -::- Uptime: 1 day, 17 hours, 27 mins
`///- .:::` Packages: 49 (guix-system)
-+++-:::. Shell: bash 5.2.37
:+/:::- Terminal: /dev/pts/0
`-....` CPU: Intel Xeon E5-2680 v2 (1) @ 2.799GHz
GPU: 00:02.0 Cirrus Logic GD 5446
Memory: 312MiB / 965MiB
8. Thoughts
Are you wondering, where to see the ended up sources code repo?
Well, if you just scroll to the bottom trying to copy something, no, please don't
I hope those who wish to follow can read throughly, with their own pc and vps ready
While it is possible to wrap things up for a project, likely "Guix In Cloud Howto", and I do have intention to make it a template under AGPL
The work has not been done yet
I currently have no time to clean things up, like my ssh pub key, my vps credential and so on in the experiment folder
also, my current directory structure is not ideal, I am thinking about how to use one directory, one deployfile, to manage many Guix VPSes, each with their own Caddyfile+Website+Backend
Contact me if you have thoughts on this, or if you happen to have 0 idea, or as a newbie somehow, just stay tuned