Creating a Capsule/Gempod on Gemini with Tiny Distroless Docker Images

Published: 30 September 2024

No sooner than 24 hours after discovering the "smolweb" and the Gemini protocol after reading the FAQ[1], I'd already decided that I absolutely must have my own capsule and gemlog.

After reading the FAQ I was intrigued and a little excited. I actually discovered Gemini through a comment[2] over at /r/linux on reddit of all places while mindlessly scrolling on my phone during a sleepless night. When I awoke, the first thing I did was head to my trusty Fedora desktop and installed the flatpak for the Lagrange Gemini browser[3].

Upon opening the browser for the first time, I felt like I'd stepped through a portal back to a period that I thought was forever lost to the sands of time. A simpler time when the web was text-heavy. A time when peoples' content was a lot more decentralized as folks blogged their thoughts and feelings on their own personal sites instead of submitting their works to massive tech conglomerates who profit both from the content itself and whatever metadata they can glean from the author. A time when you weren't tracked around the web everywhere through invasive, privacy-hostile techniques that assault your web browser at every turn which, depending on the browser you use, your browser itself may even wilfully conspire to assist in said tracking.

You get the picture.

The point is, the smolweb reminds me of my youth. It reminds me of when I was genuinely excited to open a browser and "surf" the web, never quite knowing what gems I may discover through someone's personal page or via following links in a webring. This was a feeling that, as a cranky old internet veteran, I thought I'd never experience again. But here we are. And I'm grateful for the opportunity!

The remainder of this post will focus on some technical details of how I've harnessed various open-source technologies to self-host my own Gemini site (capsule) and Gemini blog (gemlog).

Kudos and Thanks

Before I get started, however, I must give my very first Gemini shout-out to John Bowdre and his runtimeterror gemlog entry[4]:

Self-Hosted Gemini Capsule with gempost and Github Actions - gmi.runtimeterror.dev

This gemlog post pointed me in all the right directions in order to get started on my own journey as someone who came in completely unaware of the common software and such used for reading and serving capsules as well as utilizing gemtext for writing pages. John also does some really cool stuff harnessing the power of GitHub Actions. It's well worth a read for anyone interested in self-hosting Gemini capsules, for sure. Thanks John!

Gemini Server Software - Agate

The first thing you'll need to serve a capsule is, of course, a server capable of speaking the Gemini protocol to clients and serving up requested resources to them. You'll be spoiled for choice in this regard as it turns out there's an absolute ton of options for server software. Check out kr1sp1n's server list over at Github in his collection of awesome things regarding the Gemini protocol ecosystem[5]:

kr1sp1n - A collection of awesome things regarding the gemini protocol ecosystem - Github

Many of these are in various stages of development. I decided to choose Agate after checking out various code repositories and features for a few reasons:





Your needs may vary, of course, or you may have different criteria so it's well worth studying the list to find server software that suits you. As the beauty of the Gemini protocol lies in its simplicity, it's also fairly easy to develop your own bespoke server software too should you be so inclined.

Agate via Docker - Dockerfile

As I've been keen to containerise my services, I wanted to run Agate in its own Docker container. Unfortunately, like John, I was unable to find any pre-built containers for Agate. I took this as an opportunity, however, to focus on building an ultra-minimalist, lightweight Docker image that could be used to deploy Agate in its own container.

To do this, I wanted to use a multi-stage build process to abstract the build artefacts away from the final deployed image that would be used in production. I wanted the Agate server to run within a Distroless[6] image both for its tiny size as well the enhanced security you get from minimising the attack surface of the image by removing practically everything not necessary to run the binary, such as distro package managers, etc.

This is my Dockerfile for building my Agate production image:

FROM rust:latest AS build
WORKDIR /opt/agate
RUN cargo install --root /opt/agate agate

FROM gcr.io/distroless/cc
WORKDIR /opt/agate
COPY --from=build /opt/agate/bin /
ENTRYPOINT ["/agate"]

The first part harnesses the latest Rust image and installs the Agate server binary via Cargo to the custom /opt/agate directory.

The second part uses the distroless/cc image and copies the Agate binary from the previous build image into the root of the distroless image for execution. There are actually a few other distroless image types and if you want to understand the difference between them it's well worth checking out this well-illustrated rundown by Ivan Velichko[7]:

What's Inside Of a Distroless Container Image: Taking a Deeper Look - iximiuz.com

compose.yaml for Docker Compose

Once the Dockerfile for building my image was done, the next part of the process was writing a compose.yaml file for use with docker compose to orchestrate starting the Agate service, exposing the necessary port, mounting the appropriate volumes, and configuring the right commands to run the Agate server.

The following is my compose.yaml, which is in the same directory as my Dockerfile:

services:
  agate:
    restart: always
    build: .
    image: tiff/agate_gemini_d
    pull_policy: never
    container_name: tiff_gemini_capsule_agate_daemon
    user: 1000:1000
    volumes:
      - /home/tiffany/gemini-capsule/content:/opt/agate/content
      - /home/tiffany/gemini-capsule/certificates:/opt/agate/.certificates
    ports:
      - "1965:1965"
    command: >
      --content /opt/agate/content --addr 0.0.0.0:1965
      --hostname=capsule.valkyrieshowl.com --lang en-GB

There's nothing particularly exotic about this. I've chosen to run the service in the context of user ID 1000, which maps to my regular user account (tiffany) on the host system. Within my user's home I've pre-created a directory called gemini-capsule and within that created both the "content" and "certificates" directories which I've gone ahead and configured as volumes mounted at /opt/agate/content and /opt/agate/.certificates respectively. It is important to create these in advance with your regular user if you're doing it this way, otherwise docker will create these automatically and they'll be owned by the root user. The Agate service will then fail giving you "permission denied" errors when it attempts to auto-create certificates for your hostname. If that happens, you can always fix the issue by using chown to set the correct owner of the contents and certificates directories to ensure they're owned by whichever user you have your Agate server running as.

Once the compose.yaml file is in place, running this will build the necessary image and bring it online: docker compose up -d

The final size of my distroless image containing the Agate binary is just a hair over 26MB. That's pretty small, especially compared to the rust:latest image I used to build Agate that weighs in at over 800MB!

DNS and Firewall Configuration

After ensuring the Agate server was up and running in its container, the next step I took was creating the necessary DNS record for my domain and ensuring that the firewall was configured to permit the traffic on port 1965 which, I've since discovered, is a delightfully geeky reference to the year of the first crewed space flight as part of NASA's Project Gemini[8].

For DNS there's nothing fancy needed for Gemini here, just a simple A record ensuring your domain points to the IP of the server your Agate container is operating on or a CNAME entry for a subdomain if you have one dedicated to your capsule.

In my case I have a DNS A record for valkyrieshowl.com and a CNAME for capsule.valkyrieshowl.com that points to the base domain.

Serving Content

At this point, you're all set up to serve up some content on Gemini via your capsule! Capsule pages are written in a language called "gemtext" markup, which is essentially a stripped down version of Markdown with a different format for creating links. Gemtext files end with the .gmi extension. Helpfully, there's a gemtext markup reference page over at the Gemini project's capsule[9]:

A quick introduction to "gemtest" markup - geminiprotocol.net

My First index.gmi

The Agate server serves files from the configured "content" directory and first looks for a file named "index.gmi" to serve within a directory if no other file is specified as part of the request.

I created my very first index.gmi file in my content folder and added this to it with my favourite editor vim:

# Hello World!
This is my first very gemtext page on my capsule!

Upon navigating to capsule.valkyrieshowl.com I was pleased to see that the fruits of my labour had paid off and I was looking at my very first, albeit rather inglorious, capsule page!

Creating a Gemlog with Gempost

Now, one of the main reasons I wanted my own capsule in the first place is so I could host my very own blog, or "gemlog" in Gemini terms. It would be fairly simple to maintain a gemlog just by creating a new .gmi file and linking it from the index.gmi file every time I wanted to create a new entry. This method has a couple of pitfalls, however. The first being the tediousness involved and the second giving readers no easy way to subscribe to the gemlog via a feed using Atom or RSS.

This is where a neat piece of software, also written in Rust, called gempost comes in[10]:

justlark/gempost - A simple static site generator for creating a blog on the Gemini protocol - Github

Gempost is a basic static site generator that can auto-generate an index file linking to your individual posts, as well as store metadata for each post in a yaml sidecar file which is used to auto-generate a subscribable Atom feed for your readers. This is perfect for my needs!

The README in that Github repository does an excellent job of outlining how to install and use gempost but I'll go through my process anyway as a real-world example.

Installing Gempost

To install gempost, you must first install Rust. The easiest way of doing so is by downloading Rustup as documented on the Rust website[10]. After installing Rust using Rustup, it'll also install Rust's package manager called Cargo which can then be used to install gempost via the command: cargo install gempost

Once installed, you'll then be able to invoke the gempost binary to initilize your gempost project.

Initilizing Your Gempost Project

In my case, as shown previously, I have everything related to my Gemini capsule located at /home/tiffany/gemini-capsule/ whose structure currently looks like this:

.
├── certificates
│   └── capsule.valkyrieshowl.com
│       ├── cert.der
│       └── key.der
├── content
│   └── index.gmi

I want to create the gempost structure outside of the folder serving my content, so I use the following command to do so: gempost init /home/tiffany/gemini-capsule/gempost

Upon executing that command, it'll create a new folder structure. Here's what mine looks like:

.
├── certificates
│   └── capsule.valkyrieshowl.com
│       ├── cert.der
│       └── key.der
├── content
│   └── index.gmi
└── gempost
    ├── gempost.yaml
    ├── posts
    │   ├── hello-world.gmi
    │   └── hello-world.yaml
    ├── static
    │   └── index.gmi
    └── templates
        ├── index.tera
        └── post.tera

You'll be reminded to edit the gempost.yaml file to configure your capsule's title, URL, and other options which you should do so. I wanted to serve the gemlog posts from /gemlog rather than /posts as a matter of preference too, so I edited the post_path, index_path, and feed_path in the config from posts/ to gemlog/ for that purpose.

I also edited the index.gmi file within the static directory to personalise it to me and link to the /gemlog directory so visitors could click on it and be served with the auto-generated index file showing my posts.

Additionally I made a small tweak to the template file in /templates/index.tera to move the feed URL above the list of posts for easy access.

Generating and Serving Your Gemlog

Since initilizing the project also generated a sample hello-world.gmi file within the posts directory, it is possible at this point to build your gemlog for testing. This can be done using the following command within the folder where your gempost.yaml configuration file resides: gempost build

Inspecting the structure of the gempost directory shows what this generates:

├── posts
│   ├── hello-world.gmi
│   └── hello-world.yaml
├── gempost.yaml
├── public
│   ├── index.gmi
│   └── gemlog
│       ├── atom.xml
│       ├── hello-world.gmi
│       └── index.gmi
├── static
│   └── index.gmi
└── templates
    ├── index.tera
    └── post.tera

We can now see a new folder public/ has been created, and within it is an index.gmi file from the static directory. We can also see the gemlog/ folder which has the auto-generated index.gmi file based off the index.tera template that lists all our posts as well as the atom.xml feed file. Perfect!

In order to actually serve these at your capsule, you'll need to move the contents of the public/ directory to whichever directory you designated as your Agate content location. In my case, this would do the trick: mv public/* ~/gemini-capsule/content

It's worth noting that I actually attempted to just set Agate's content directory to the public folder generated by gempost. That proved to be problematic, however, as issuing the gempost build command actually deletes and re-creates the folders. This seems to be a problem for Agate as it began refusing to serve content from the newly created directories until it was restarted. Obviously restarting the server after every post wasn't viable, so moving those files from the public directory to the content directory is the way to go. It's possible this quirk will disappear in the future.

Creating a New Post with Gempost

Creating a new post is straightforward. Simply use the command: gempost new . The part being what is shown for that post in the URL. For example, I may create a test page with: gempost new this-is-a-test

Doing so would generate a .gmi and .yaml sidecar metadata file within the /posts directory:

.
├── gempost.yaml
├── posts
│   ├── hello-world.gmi
│   ├── hello-world.yaml
│   ├── this-is-a-test.gmi
│   └── this-is-a-test.yaml
├── public
│   ├── gemlog
│   │   ├── atom.xml
│   │   ├── hello-world.gmi
│   │   └── index.gmi
│   └── index.gmi
├── static
│   └── index.gmi
└── templates
    ├── index.tera
    └── post.tera

I could then edit the .gmi file to create my post and the .yaml file for the metadata. There's a full example[12] of the available metadata tags in the project's Github tree. After finishing the post, building the project and moving the files in /public to the content directory would then ensure the new post is served.

Helper Scripts

While it's possible to write and publish posts using this process, I personally would find it a little tedious after awhile. For this reason I wrote a couple of scripts to help me with this.

This script asks me for a topic and a slug, then runs the new post command using those details and opens the .gmi file in my favourite text editor. This makes it quick and easy to get writing a new entry.

newpost.sh

#!/bin/bash
GEMPOST_DIR=/home/tiffany/gemini-capsule/gempost

printf "Enter the topic of the new post: "
read topic

printf "Enter the slug for the new post's URL: "
read slug

# Remove any spaces and replace spaces between words with dashes.
slug_fixed=$(echo "$slug" | awk '{$1=$1;gsub(/ /,"-");print tolower($0)}')

cd $GEMPOST_DIR
gempost new -t "$topic" $slug_fixed

$EDITOR posts/$slug_fixed.gmi

This script simply copies the contents of the public/ directory to the Agate server's content folder.

publish.sh

#!/bin/bash
CAPSULE_PATH=/home/tiffany/gemini-capsule/
GEMPOST_PATH=/home/tiffany/gemini-capsule/gempost/

cd $GEMPOST_PATH
gempost build
cp -r $GEMPOST_PATH/public/* $CAPSULE_PATH/content

I have both of these scripts configured as aliases on my bash profile so I can quickly write newpost and publish in my terminal.

And so, that's my journey thus far into the wonderful universe of Gemini! I hope someone finds this interesting or informative. Feel free to reach out to me if that is the case or you have any questions, corrections, or suggestions.

References

[1] Project Gemini FAQ

[2] Impossible-graph's comment @ reddit /r/linux

[3] Lagrange Client | Flathub

[4] Self-Hosted Gemini Capsule with gempost and Github Actions - gmi.runtimeterror.dev

[5] kr1sp1n - A collection of awesome things regarding the gemini protocol ecosystem. - Github

[6] "Distroless" Container Images - Github

[7] What's Inside Of a Distroless Container Image: Taking a Deeper Look

[8] Project Gemini - Gemipedia

[9] A quick introduction to "gemtext" markup - geminiprotocol.net

[10] justlark/gempost - A simple static site generator for creating a blog on the Gemini protocol - Github

[11] Install Rust - Rust Programming Language

[12] gempost/examples/metadata.yaml - Github

Mailbag

Comments and replies to this post are welcome. Feel free to send a cosmic raven to:

Misfin: tiff@valkyrieshowl.com

Email: surges.colts_0s@icloud.com

Back to Gemlog


Source