When Campfire - the first Once product - came out, a few of us at Ruby Zagreb pitched in to buy a copy.
Prometheus had brought fire from Mount Olympus, and we came to see the blaze. We were finally seeing how the people at 37signals - the birthplace of Rails - build Rails apps. And as a bonus, we could finally ditch our free Slack Channel!
After days of exploring the source code, the time came to set up Campfire. The purchase email included a shell command that looks like this
/bin/bash -c "$(curl -fsSL https://auth.once.com/install/1111-1111-1111-1111)This is a super elegant way to distribute apps! But how did it work? I wasn’t sure so I decided to replicate it and see.
Figuring out how Once works
Just like last time, I started to unravel this ball of yarn by following a thread - the shell script.
Since all it does is execute a script it gets from the Internet I went to see what that script was
curl -fsSL https://auth.once.com/install/1111-1111-1111-1111 #!/bin/bash set -e echo "Installing the ONCE command..." echo sudo true command_path="https://auth.once.com/install/1111-1111-1111-1111?arch=$(arch)&platform=$(uname)" curl -s $command_path > .once-tmp sudo mv .once-tmp /usr/local/bin/once sudo chmod +x /usr/local/bin/once if [[ "$OSTYPE" == "darwin"* ]]; then # Don't use sudo on macOS; Docker Desktop is likely to be running as the user /usr/local/bin/once setup 1111-1111-1111-1111 else sudo /usr/local/bin/once setup 1111-1111-1111-1111 fiThis downloads a binary for your OS and CPU architecture, installs it, and then runs it passing in your license key.
As this was a binary I couldn’t just open it up and see it’s code so I resorted to simple reverse engineering using the strings command - it returns all things that look like C strings from a given file.
... crypto/tls.masterSecretLabel crypto/tls.extendedMasterSecretLabel crypto/tls.keyExpansionLabel crypto/tls.clientFinishedLabel crypto/tls.serverFinishedLabel mime/multipart..inittask mime/multipart.ErrMessageTooLarge mime/multipart.multipartFiles mime/multipart.multipartMaxParts mime/multipart.emptyParams mime/multipart.multipartMaxHeaders mime/multipart.quoteEscaper net/textproto.errMessageTooLarge net/textproto.colon net/textproto.nl net/textproto.commonHeader net/textproto.commonHeaderOnce net..inittask net.rfc6724policyTable net.confOnce net.confVal net.netdns ...There were a lot of strings that looked like Go packages, functions and methods. I soon found a few references to cobra - a Go package for building CLI tools - so I was sure that I was looking at a compiled Go app.
... github.com/spf13/cobra.init github.com/spf13/cobra.map.init.0 github.com/spf13/cobra.GetActiveHelpConfig github.com/spf13/cobra.activeHelpEnvVar github.com/spf13/cobra.legacyArgs github.com/spf13/cobra.(*Command).HasSubCommands github.com/spf13/cobra.(*Command).HasParent github.com/spf13/cobra.NoArgs github.com/spf13/cobra.writePreamble ...This was very fortunate because I’ve started using Go at work lately and I knew that it includes a lot of metadata in its binaries (this can be turned off but isn’t by default). So I looked around and found the whole structure of the app, and all the method signatures.
once-cli/ ├── cmd/ │ └── once/ │ └── main.go │ └── internal/ ├── archiver/ │ ├── compressor.go │ └── uncompressor.go │ ├── cmd/ │ ├── auto_update.go │ ├── auto_update_off.go │ ├── auto_update_on.go │ ├── data.go │ ├── data_backup.go │ ├── data_restore.go │ ├── password.go │ ├── password_reset.go │ ├── root.go │ ├── setup.go │ ├── start.go │ ├── status.go │ ├── stop.go │ ├── update.go │ └── util.go │ ├── config/ │ ├── config.go │ ├── environment.go │ └── verifier.go │ ├── docker/ │ └── docker.go │ ├── networking/ │ ├── dns_check.go │ ├── network_check.go │ └── ssl_traffic_check.go │ └── tui/ ├── backup/ │ └── backup.go ├── components/ │ └── task_list.go ├── question/ │ └── question.go ├── restore/ │ └── restore.go ├── runner/ │ └── runner.go ├── setup/ │ ├── installer.go │ ├── registration.go │ └── setup.go └── util.goAnd I found three URLs
https://auth.once.com/install/ https://auth.once.com/verify/%d registry.once.comThe first one was the same as the download URL, the second one obviously verifies something but I didn’t know what. My guess is for the password reset functionality (look at once-cli/internal/cmd/password_reset.go) because it interpolates a number at the end (that's the %d). The third one looks like a private Docker registry.
Can I login to the Docker registry?
After set up, Once creates a config file where it stores how you configured it.
// contents of /root/.config/once/config.json { "token": "1111-1111-1111-1111", "product": "campfire", "product_name": "Campfire", "email_address": "[email protected]", "ssl_domain": "chat.rubyzg.org", "validation_token": "PHONY_VALIDATION_KEY", "secret_key_base": "PHONY_SECRET_KEY_BASE", "vapid_private_key": "PHONY_PRIVATE_KEY", "vapid_public_key": "PHONY_PUBLIC_KEY", "storage_location": "/var/once/campfire", "cron_hour": 2, "once_binary_etag": "" }Notice that it stores the license key and my email. Also notice that it calls the license key "token". My guess is that it uses the license key as an auth token with the registry to pull updates. That's a common way to do token auth with Docker Registries. Let's try that.
docker login \ --username '[email protected]' \ --password '1111-1111-1111-1111' \ 'https://registry.once.com' Login SucceededNow, where did it get my email, the product, and product name from? These weren't asked during set up. I'm guessing that the install URL is in the binary because it can respond with HTML / text for the install script but it can probably also respond to JSON. Let's try that
curl https://auth.once.com/install/1111-1111-1111-1111.json | jq { "token": "1111-1111-1111-1111", "product": "campfire", "product_name": "Campfire", "email_address": "[email protected]", "ssl_domain": null, "validation_token": "<some-base64-string>" }I was curious about the validation token as it was obviously a Base64 string, but decoding it returned complete gibberish - it doesn't even look like unicode - so I assume that it's either completely random or an encrypted payload. Maybe that's what the verify endpoint is for?
My guess is that the token is used to update the "ssl_domain" field with the Once auth server after the install completes or during the DNS check. That would mean that the install endpoint probably also responds to a POST request.
Anyway, I now had a good understanding of how Once works.
How Once works
Once consists of three main parts:
- The Auth server
- The private Docker Registry
- The CLI app
The CLI handles Docker installation and configuration on the client, pulling and running of product Docker images, product container management, automatic-updates, backups, restores, and a few other things.
The private Docker Registry is, well, just a Docker Registry... It stores and distributes Docker images.
The license key you get is used as an access token to the registry. With it the CLI can pull images from the registry. That token probably grants access to a single repository in the registry - just for the product you purchased.
Building Twice
Now the fun part, making a clone of Once.
I decided to call this project "Twice" as a joke on the fact that it's a clone of Once - it's a second implementation. The source code is available on GitHub if you want to explore it, build, modify, or run Twice for yourself.
My main motivation is to learn more about how Once is set up, but a nice side effect is that I'll get a distribution system out of it - in the case that I decide to make an app and sell it. With that in mind, my primary goal is to implement just the setup CLI command. I'll work on everything else later.
Just like with Simpliki, I'll start from the center and work myself out. In the case of Twice the center is the CLI app - specifically the setup command.
As I've been working with Go at work lately, I felt confident in using it for the CLI. This would be a nice exercise to sharpen my Go skills. Otherwise I'd probably go with Rust as I have more experience with it, or I'd give Tebako a try - a new way to package Ruby apps into a single binary.
So I started a new Go project, added cobra to it and followed the getting started guide to create a CLI app with a setup command that accepts one argument - the license key.
go run cmd/twice/main.go setup --help Install all the necessary dependencies to run a product and then installs the product associated with the given license key Usage: twice setup <license-key> [flags] Flags: -h, --help help for setupI like the version with the TUI more - it feels refined - and I think I'll revisit it once I'm more comfortable with Go and Bubble tea (the library that renders the TUI).
As I was building the CLI I started the Auth app and mocked a few endpoints just to get the setup command done. I didn't implement any UI and relied heavily on "bin/rails db:fixtures:load" to give me some data to work with. These endpoints were plain Rails controllers that rendered JSON.

With most of the CLI out of the way, and with some of the Auth app done I turned my attention to the Docker Registry.
There are a few Docker Registries out there:
I liked the idea of having a Registry that's written in Rails so I tried Portus, but I couldn't get it to run locally - there are multiple known issues and the project was archived 2 years ago.
Harbor is a conglomeration of micro services. When I saw that I had to run 4 or more containers just to get the thing going I gave up.
This left me with Registry. It has everything I need - access control via token auth to a configurable auth server. It has no UI or API, but this is a plus as I don't have to think about customers trying to login to the registry UI or interacting with it other than pulling images. And I can make the system stateless, thus less error-prone.
All I had to do to get the registry working is to generate a self-signed certificate
And set a few environment variables that configure it to use token auth, tell it to use my auth server, and to verify the auth tokens using the certificate I just generated
# Use token auth REGISTRY_AUTH=token # Use `http://localhost:3000/registry/auth` as the auth server REGISTRY_AUTH_TOKEN_REALM=http://localhost:3000/registry/auth # This isn't too important REGISTRY_AUTH_TOKEN_SERVICE=registry # This has to match what you return from the auth server and what's in the certificate REGISTRY_AUTH_TOKEN_ISSUER=registry-auth # This specifies which certificate to use to verify tokens REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/certs/auth.pemThat's it. Here is a Docker compose file for it
services: registry: image: registry:2 # On Linux network_mode: host # On Mac # ports: # - 5000:5000 environment: - REGISTRY_AUTH=token - REGISTRY_AUTH_TOKEN_REALM=http://localhost:3000/registry/auth - REGISTRY_AUTH_TOKEN_SERVICE=registry - REGISTRY_AUTH_TOKEN_ISSUER=registry-auth - REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/certs/auth.pem - REGISTRY_STORAGE_DELETE_ENABLED=true volumes: - ./registry/tmp/registry_data:/var/lib/registry - ./registry/certs:/certs:roNow the only thing left was to finish the Auth server.
First, I tackled registry token auth. This requires a single endpoint that responds to both POST and GET requests
match "/registry/auth", to: "registry#auth", as: :registry_auth, via: %i[ get post ]The endpoint receives the users email and token via HTTP basic auth and responds with a JSON payload that looks like this
{ "token": "signed.jwt.token", "expires_in": 300, "issued_at": "2025-05-15T16:55:23Z" }The token from the response is a JWT that contains a list of access permissions - these define which actions the user can perform.
{ "iss": "registry-auth", // Has to match the value set in REGISTRY_AUTH_TOKEN_ISSUER "sub": "stankoexample.com", // Username of the person logging in "aud": "registry", // Has to match REGISTRY_AUTH_TOKEN_SERVICE "exp": 1747329069, "nbf": 1747328784, "iat": 1747328784, "jti": "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed", "access": [ { "type": "repository", "name": "campfire", "actions": ["pull"] } ] }The tricky part was to sign the token with the certificate such that the registry would accept it as valid. This took me some time to figure out. To get it to work I had to set two additional JWT headers called KID and X5C, which tell the registry exactly which certificate it should validate the token with. Both headers are derived from the signing certificate itself.
KID = OpenSSL::Digest .new("SHA256") .update(PRIVATE_KEY.public_key.to_der) .hexdigest .upcase .scan(/.{1,2}/) .join(":") X5C = Base64.strict_encode64(PUBLIC_KEY.to_der)Putting it all together, the controller looks like this
def auth authenticate_or_request_with_http_basic do |email, license_key| user = User.find_by(email_address: email) deny_access and return if user.blank? license = user.licenses.find_by_key(license_key) token = user.generate_registry_access_token_to_product(license.product, service: params[:service]) deny_access and return if token.blank? payload = { token: token.to_s, expires_in: token.duration.to_i, issued_at: token.issued_at.iso8601 } render(json: payload, status: :ok) end endNotice that the token is an object. This provides a nice domain language (DDD domain language) that makes it easier to work with, and reason about, the token. If I want the duration or the issued at time I can ask the token for it; if I want to turn it into a string I can do that too.
At this point I noticed that I didn't implement a way to push images to the registry. To fix that I introduced a new type of user - the Developer. STI made the most sense for this, so I converted the customers table to a users table, added a type column and a few extra columns.

Unlike Customers, Developers login with a password tied to their account and they get full access to the registry.
Second, the install endpoint. This was straight forward
# config.routes.rb get "/install/:license_key", to: "install#install", as: :install, defaults: { format: :text } # app/controllers/install_controller.rb def install respond_to do |format| format.text format.json end endAnd then I just had to create two separate view files. One for the script in app/views/install/install.text.erb
<%# vim: set ft=bash: %> #!/usr/bin/env bash set -e echo "Installing the TWICE command..." echo sudo true command_path="<%= install_download_url %>?arch=$(uname -m)&platform=$(uname)" curl -s "$command_path" -o .twice_tmp sudo mv .twice_tmp /usr/local/bin/twice sudo chmod +x /usr/local/bin/twice if [[ "$OSTYPE" == "darwin"* ]]; then # Don't use sudo on macOS; Docker Desktop is likely to be running as the user /usr/local/bin/twice setup <%= @license.key %> else sudo /usr/local/bin/twice setup <%= @license.key %> fiAnd another in app/views/install/install.json.jbuilder
# vim: set ft=ruby: json.key @license.key json.owner do json.(@license.owner, :id, :email_address) end json.product do json.(@license.product, :id, :name, :repository) json.registry ENV.fetch("REGISTRY_URL", "localhost:5000") endNotice that I strayed a bit from 37signals' implementation when it comes to downloading the binary. I decided to separate that out into another controller action because the install endpoint already does too many things in my opinion, and adding file sending to that seems like way too much responsibility for one endpoint.
I also made the decision that the auth server would directly send the file to the client instead of e.g. redirecting to something like S3. This makes it easier to self-host twice, and due to a trick it’s no less performant.
Here is the download action
def download platform = sanitize_file_name_part(params[:platform]) arch = sanitize_file_name_part(params[:arch]) filename = ["twice", platform, arch].compact.join("-") file_path = Pathname.new(Rails.root.join("storage", filename)) if file_path.exist? send_file file_path, type: "application/octet-stream", filename: filename else head :not_found end endIt assumes that the requested binary will be called "twice-#{platform}-#{arch}", then it goes and checks if such a file exists in the storage directory, and if it does it sends it.

In production this won't actually stream the file from Ruby, instead it sets a special header that tells Thruster to send the file which makes things way more efficient.
Third, I added authentication using the new auth generator in Rails 8, and a UI for managing customers, developers, products and licenses.
And that was it. Twice was done. Here is the demo
Some loose thoughts
When writing about building Simpliki I left out an important part - what I intentionally didn't do.
For Twice, I intentionally didn't implement domain name tracking as I think that it's not important. It can't prevent people from running the docker container how and where they want. It would add complexity and I don't see the benefit of it, so I ditched it.
I do see the utility in all the backup and management commands that the Once CLI has, which that I didn't implement in Twice. I intend to return to that at some point. But they aren't important for distributing apps so I skipped them.
Twice, currently, has the same problem as Once - it doesn't play well with other apps or Kamal. This isn't a problem if you rent a VPS specifically to run a Once app, but if you have a large bare metal machine like me then this is cumbersome. I'd like to change it so that it deploys kamal-proxy and then proxies requests to different apps through it. This would allow it to run multiple apps on the same server.
That's also why the Twice CLI doesn't keep a config file - I'd like to make it so that you can run multiple apps, so the config has to support that. This was extra work that I think isn't important for now. And I'd like to be able to install an app from different Twice instances on the same server. Something like
twice setup [email protected] twice setup [email protected]This would make it more versatile - but, again, that's way beyond the scope of creating a clone to learn how Once works.
I liked working with Go. After years of using Rust, having a garbage collector and not having to think about results, unwrapping, lifetimes or borrowing was a breath of fresh air. Though, the way it declares modules still doesn't sit right with me. If you didn't look at the source code, each directory is its own module, and all files in the directory get essentially concatenated together into that module at compile time. It works, and I can't point out any problems with that, but it feels weird.
The folks at 37signals made a really elegant distribution system for their apps. I had a lot of fun figuring out how it works and I learned a lot along the way.