---
title: "Building Twice: A clone of Once"
url: https://stanko.io/building-twice-a-clone-of-once-gJKxLYCe26Ak
author: Stanko Krtalic Rusendic
published: 2025-05-16
updated: 2026-03-09
tags: [docker, go, ruby]
---

# Building Twice: A clone of Once

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

```bash
/bin/bash -c "$(curl -fsSL https://auth.once.com/install/1111-1111-1111-1111)
```

I ran that, [and after solving a few
issues](https://stanko.io/running-campfire-behind-traefik-LrGQM0GE0FVd), I had a
Docker container running Campfire.

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](https://stanko.io/how-i-stumbled-upon-strada-while-forwarding-an-email-3W8mbS5KSKXs),
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

```bash
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
fi
```

This 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](https://www.man7.org/linux/man-pages/man1/strings.1.html) - it returns
all things that look like C strings from a given file.

```plain
... 
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](https://go.dev/) packages,
functions and methods. I soon found a few references to
[cobra](https://github.com/spf13/cobra) - a Go package for building CLI tools -
so I was sure that I was looking at a compiled Go app.

```plain
... 
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.

```plain
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.go 
```

And I found three URLs

```plain
https://auth.once.com/install/ 
https://auth.once.com/verify/%d 
registry.once.com 
```

The 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*](https://pkg.go.dev/fmt)).
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.

```json
// contents of /root/.config/once/config.json
{
  "token": "1111-1111-1111-1111",
  "product": "campfire",
  "product_name": "Campfire",
  "email_address": "stanko@example.com",
  "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.

```bash
docker login \
  --username 'stanko@example.com' \
  --password '1111-1111-1111-1111' \
  'https://registry.once.com'

Login Succeeded
```

Bingo!

Now, 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

```bash
curl https://auth.once.com/install/1111-1111-1111-1111.json | jq
{
  "token": "1111-1111-1111-1111",
  "product": "campfire",
  "product_name": "Campfire",
  "email_address": "stanko@example.com",
  "ssl_domain": null,
  "validation_token": "<some-base64-string>"
}
```

Two for two!

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:

1. The Auth server
2. The private Docker Registry
3. The CLI app

![An overview of Once](https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/once_overview.png/911w.webp)

T*he Auth server* handles token validation for the registry, distribution of the
CLI binary, license key inspection, and probably [the order created webhook from
Shopify](https://shopify.dev/docs/api/webhooks?reference=toml#list-of-topics-orders/create).

*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](https://github.com/monorkin/twice) 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](https://stanko.io/building-simpl-77CAzym51p5a), 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](https://github.com/tamatebako/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](https://cobra.dev/#getting-started) to create a CLI app with a
*setup* command that accepts one argument - *the license key*.

```plain
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 setup
```

At this point I got lost in the weeds. Once has this nice [TUI (terminal user
interface)](https://en.wikipedia.org/wiki/Text-based_user_interface) that walks
you through the setup process so, naturally, I wanted to make a nice TUI too.

[Video: Twice TUI](https://media.stanko.io/originals/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_tui_demo.mp4)

I spent a couple of hours working on it, I got quite far, but in the end I
realized the TUI didn't matter. It was just slowing me down, so I ditched it and
replaced it with good old *puts*.

[Video: Twice CLI](https://media.stanko.io/originals/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_demo.mp4)

This was waaaaay quicker to implement and allowed me to focus on the actual
logic - like [figuring out if docker was installed, is it
running](https://github.com/monorkin/twice/blob/c22d6361becc4b5c66947762e5dc7d09090da576/cli/internal/docker/check.go),
[writing an API
client](https://github.com/monorkin/twice/blob/c22d6361becc4b5c66947762e5dc7d09090da576/cli/internal/api/license.go),
[interacting with
Docker](https://github.com/monorkin/twice/blob/c22d6361becc4b5c66947762e5dc7d09090da576/cli/internal/docker/pull.go),
etc. - instead of rendering a fancy UI.

I 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](https://github.com/charmbracelet/bubbletea) (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.

![The models I had for Twice](https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/original_models.png/1435w.webp)

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:

1. [CNCF Registry](https://distribution.github.io/distribution/)
2. [Harbor](https://github.com/goharbor/harbor)
3. [Portus](https://github.com/SUSE/Portus)

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

```bash
openssl req -x509 -newkey rsa:4096 -nodes \
    -keyout ./auth.key \
    -out ./auth.crt \
    -days 365 \
    -subj "/CN=registry-auth" \
    -addext "basicConstraints=critical,CA:true" \
    -addext "keyUsage=keyCertSign,digitalSignature" \
    -addext "subjectAltName=DNS:registry-auth"
```

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

```bash
# 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.pem
```

That's it. Here is a Docker compose file for it

```yaml
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:ro
```

Now 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

```ruby
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

```json
{
  "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.

```json
{
  "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](https://curity.io/resources/learn/self-contained-jwts/), which tell the
registry exactly which certificate it should validate the token with. Both
headers are derived from the signing certificate itself.

```ruby
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

```ruby
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
end
```

Notice that [the token is an
object](https://github.com/monorkin/twice/blob/c22d6361becc4b5c66947762e5dc7d09090da576/auth/app/models/docker_registry/token.rb).
This provides a nice domain language
([DDD](https://en.wikipedia.org/wiki/Domain-driven_design) 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](https://guides.rubyonrails.org/association_basics.html#single-table-inheritance-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.

![The models in Twice after adding developers](https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/final_models.png/1422w.webp)

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

```ruby
# 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
end
```

And then I just had to create two separate view files. One for the script in
*app/views/install/install.text.erb*

```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 %>
fi
```

And another in *app/views/install/install.json.jbuilder*

```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")
end
```

Notice 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

```ruby
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
end
```

It 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.

![The download endpoint in action](https://media.stanko.io/variants/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_cli_download_demo.png/888w.webp)

In production this won't actually stream the file from Ruby, [instead it sets a
special
header](https://api.rubyonrails.org/classes/ActionController/DataStreaming.html#method-i-send_file)
that tells [Thruster](https://github.com/basecamp/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.

[Video: Demo of the Auth app UI](https://media.stanko.io/originals/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/auth_ui_demo.mp4)

And that was it. Twice was done. Here is the demo

[Video: Demo of Twice installing an app](https://media.stanko.io/originals/articles/building-twice-a-clone-of-once-gJKxLYCe26Ak/twice_demo.mp4)

### 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](https://kamal-deploy.org/). 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](https://github.com/basecamp/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

```bash
twice setup 1111-1111-1111-1111@auth.foo.com
twice setup 2222-2222-2222-2222@auth.bar.com
```

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.
