Hosting a Gemini Server with Fly.io

In the long tradition of "How I Rebuilt My Blog" developer posts, here's my humble entry. I did not set out to build a new website, but here I am.

Before we get started let's go over a few questions you might have from the title:

Gemini is a new(ish) protocol for content, a successor to Gopher that is like a stripped down version of the Web.

Project Gemini

Fly.io is a service for deploying apps to the edge. It has an interesting take that differs from competitors in that it deploys Dockerfiles, which means you can deploy almost anything.

Fly.io

With that out of the way, back to the story. I've been interested in Fly.io for a while. Recently I discovered that you can deploy non-HTTP apps as well. Coupled with the use of Dockerfiles, this means that Fly.io can be used to deploy any sort of networking app, using practically any language.

Filled with excitement I looked for something to build. Quickly I landed on a Gopher site. Gopher is an older protocol that is like a stripped down version of the Web. You have plain text, links, a few images, and not much else. It means that your pages load very quickly and building a site is likewise simple.

When I started this project I was unaware of Gemini! I'll skip past this section, but suffice to say, deploying a Gopher server to Fly.io was remarkably simple.

Skipping ahead, once I did discover Gemini I quickly fell in love. Gemini is like Gopher but more modern. The native text format is called "gemtext" and is a little like a slim version of Markdown.

Choosing my stack

For the project I chose Go as the language for a few reasons:

Deploying a Go app to Fly.io is very simple, this is my entire Dockerfile:

FROM golang:1.17

RUN     mkdir /app
WORKDIR /app
ADD     go.mod go.sum common.go main.go http.go html.go styles.css /app/
RUN     go build

CMD     ./matthewsspace

My ideal flow was to write posts in gemtext, deploy them ... somewhere, and then have them served by the Gemini server and an HTTP server as well (given that few people use Gemini, I want my posts to be viewable on the web).

I looked into a number of Headless CMS options and ultimately hated them all. I came close to hooking up Notion but even that seemed too complex.

Instead I went with using plain AWS S3. Using Rclone I can sync files between S3 and my local filesystem in a second. This worked out great.

RClone

The hard part

Building the Gopher site was easy. Gemini was slightly less so thanks to TLS. Gemini requires TLS, which is a good thing! But TLS is still a major pain to deal with if your host is not doing it for you.

Most hosts automate TLS these days, and Fly.io is no exception. However this only works for HTTPS. Any other protocol you have to load the certificate within your application.

There's a support thread about adding the certificate to your local Docker container so the app can load it. If they add this feature this all becomes much easier.

Here's what I wound up doing instead:

Let's Encrypt

Cerbot

I installed Cerbot locally and ran the command to generate a certificate manually. Certbot is a client for generating certificates through the Let's Encrypt CA. In order to confirm you are the owner of your domain it asks you to respond to a request like this:

https://example.com/.well-known/acme-challenge/$TOKEN

Normally cerbot can be configured to work with an HTTP server like Apache or Nginx. Since I'm using Fly.io that wouldn't really work, so I had to instead due the manual method and modify my server code, deploy it to Fly.io, and then click Continue so that the challenge could be completed.

This gave me certificate files, and to get them to Fly.io I decided to set them as environment variables. You can do that with `flyctl` like so:

flyctl secrets set PRIVKEY="-----BEGIN PRIVATE KEY----- ..."

Then to use the certs I get grabbed them in the code and used like so:

pubkey := []byte(os.Getenv("PUBKEY"))
privkey := []byte(os.Getenv("PRIVKEY"))

cert, err := tls.X509KeyPair(pubkey, privkey)
// ...

Then simply deployed and crossed my fingers that it all worked:

flyctl deploy

This presents one big problem however. My certificate is going to expire in a few months and I'll have to do this process all over again! I'll need to figure out a solution before then. I'm thinking I can set up a certbot server somewhere (maybe Fly.io!) and proxy .well-known/acme-challenge/ requests to that.

Proxying to HTTP

The vast majority of my time was spent getting TLS working. Once I had that figured out the rest was easy.

I wanted a workflow like this:

For that last piece I wanted to avoid the multiple deployment problem. That is, I didn't want to build my site to HTML. Instead I wanted it to *convert* to HTML on the fly.

With Fly.io you can listen to multiple ports in the same app. This means i can start a Gemini and HTTP server in the same process. With Goroutines it's easy to do this! My fly.toml has this secition:

[[services]]
  internal_port = 1965
  protocol = "tcp"

  [[services.ports]]
    port = "1965"

[[services]]
  internal_port = 8090
  protocol = "tcp"

  [[services.ports]]
    handlers = ["http"]
    port = "80"
    force_https = true  # optional

  [[services.ports]]
    handlers = ["tls", "http"]
    port = "443"

This is setting up:

My HTTP server proxies all requests to the Gemini server using a Gemini client. I'm using go-gemini for both the server and client:

go-gemini

Even though the HTTP server is proxying to the Gemini server, since it's all on the same box (the same process actually) it happens so fast that you don't notice.

For pages that are text/gemini, which are most, it then converts those to HTML, again on the fly. I don't believe that Gemini has support for caching at the moment, but this seems like something I can probably add on top at the HTTP level in the future. Everything's so fast even without it that I don't know when/if I'm likely to pursue that, however.

Conclusions

Fly.io is a pretty outstanding service that is quite flexible about what you can build on it. I can see myself building more Gemini apps on top, perhaps some proxying of HTTP content, some dynamic pages (forums maybe?), the possibilities are endless.

I'm hopeful they provide a way to access the SSL cert within the Docker container, that's currently the only thing preventing this from being a seamless experience.