Open, source-available — the new KeygenStar us on GitHub arrow_right_alt

How to License and Distribute a Private Docker Image

Monday, December 13th 2021

Continuing our series on licensing and distributing software products using Keygen — today, we're going to be diving into private Docker images. We're going to be taking advantage of Keygen's API, for licensing and distribution, as well as Keygen's brand new CLI, for publishing application releases.

At the end of this post, we'll be securely distributing a private Docker image to licensed users, without having to run and manage a full-blown Docker registry.


It has become increasingly common for Independent Software Vendors (or ISVs) to ship on-premise and multi-prem software solutions using Docker containers. Containerization allows on-prem software applications to be easily installed by customers and other end-users, while giving the ISV control over the application environment. This not only reduces the support burden on the ISV, but it also decreases onboarding efforts since Docker containers are relatively plug-and-play in modern enterprise environments.

Today, we'll be working with Dockerizing a Node application, but the same could be done for most other types of applications. From Go, to Ruby on Rails, to Python's Django — the same principles apply — and that's why Docker is great for shipping on-prem.

Building our Docker image

To keep it simple, we're going to create a "Hello, world!" Node app, packaged up into a Docker image, which we'll distribute to our licensed users using Keygen.

Add a package.json file

To kick things off — let's create a base package.json file. In it, we'll define our main and add a script for running the app.

cat << EOF > package.json
{
"private": true,
"main": "main.js",
"scripts": {
"start": "node main.js"
}
}
EOF

Create our Express app

Next, we'll add our main.js file. This is where our app's logic will live.

touch main.js

Then we'll add a couple dependencies, such as express, and morgan for logging.

yarn add express morgan

Then we'll add a simple "Hello, world!" Express app that runs on port 8080.

const { PORT = 8080 } = process.env
 
const express = require('express')
const morgan = require('morgan')
const app = express()
 
app.use(morgan('combined'))
 
app.get('/', (req, res) => res.send('Hello, world!'))
 
app.listen(PORT, () => {
console.log(`Server is running on port ${port}`)
})

Add a Dockerfile

Then we'll add our Dockerfile.

touch Dockerfile

We'll keep it super simple and use Alpine Node as our base image. Then we'll install dependencies with yarn and boot our Express app on port 8080.

FROM node:alpine
 
WORKDIR /usr/src/app
COPY . .
 
RUN yarn install
 
EXPOSE 8080
CMD ["yarn", "start"]

Add a .dockerignore file

Finally, we'll create a .dockerignore alongside our other files.

touch .dockerignore
 
echo 'node_modules/' >> .dockerignore
echo '.git/' >> .dockerignore
echo '*.log' >> .dockerignore

This will prevent any locally installed Node modules and as well as any debug logs from being copied onto our final Docker image.

Running our Docker image

To review what we're done so far — we've created a small "Hello, world!" Express app, with a minimalistic Dockerfile, and a .dockerignore to prevent certain local files from being included in our final image.

Now that we've got all that out of the way — let's test our app! First, let's build our image and then boot it. Docker will expose it on port 49160 of localhost.

docker build . -t keygen/hello_world
# => Sending build context to Docker daemon 21.5kB
# Step 1/6 : FROM node:alpine
# ...
# Successfully built df58ca39ab02
# Successfully tagged keygen/hello_world:latest
 
docker run -p 49160:8080 -d keygen/hello_world
# => 01aa53edb95783e2ea786c4cf95a82e2cc0e3264d5d1a8df437d3a32e3b254a1
 
docker ps
# => CONTAINER ID IMAGE STATUS PORTS
# 01aa53edb957 keygen/hello_world Up 1 minute 0.0.0.0:49160->8080/tcp
 
docker logs 01aa53edb957
# => Server is running on port 8080

Now that it's booted, let's fire off an HTTP request using curl and make sure everything's working properly. Our app should be on localhost:49160.

curl http://localhost:49160
# => Hello, world!

Next, let's package our image.

Packing our Docker image

Keygen doesn't run a full Docker registry, so tagging and pushing an image with docker push isn't supported. Instead, Keygen's distribution API acts more like a private S3 bucket, but only accessible by licensed users. We'll be packing our image into a tar file, which we'll publish later on using the keygen command line tool.

To tag and pack a Docker image into a tarball, we'll use the docker save command.

docker image ls
# => REPOSITORY TAG IMAGE ID CREATED SIZE
# keygen/hello_world latest df58ca39ab02 9 minutes ago 174MB
# node alpine bb1fcdaff936 10 days ago 170MB
 
docker tag df58ca39ab02 keygen/hello_world:v1.0.0
# => Tagged image: keygen/hello_world:v1.0.0
 
docker save keygen/hello_world:v1.0.0 | gzip > hello_world.tar.gz

We should then have a packed Docker image, ready for upload.

ls -sh hello_world.tar.gz
# => 49M hello_world.tar.gz

Publishing our Docker image

Next, we'll want to sign up for a free Keygen account. Once we've done that, we'll want to use the Dashboard to create a Product resource, and also generate a Token for our new product. This will allow us to authenticate with Keygen's API and authorize Keygen's CLI to upload releases on your account.

Create a product

On your account Dashboard, the first thing we'll need to do is create a new Product. In the Products section, click New Product and enter the name of your product and click Create Product. This will generate a product ID that we'll pass to Keygen's CLI.

Create a product token

Next on the Dashboard, we'll want to create a Token to authenticate and authorize the CLI so that we can publish releases. You'll want to click on the Tokens section, click New Product Token and create a new token. For security reasons, Keygen does not store your tokens. Copy it and store it somewhere secure.

Never share this token. It gives full access to your product, including the ability to create and read licenses, and publish new releases.

Publishing a release

If you haven't already, you'll want to download the keygen CLI. Once you've done that, we'll use the keygen CLI to publish our v1.0.0 release. We'll pass in our previously created values, our Account ID, Product ID, and our Product Token.

# Export env variables for the CLI (or you could pass via flags e.g. --account)
export KEYGEN_ACCOUNT_ID="<YOUR_KEYGEN_ACCOUNT_UUID>"
export KEYGEN_PRODUCT_ID="<YOUR_KEYGEN_PRODUCT_UUID>"
export KEYGEN_PRODUCT_TOKEN="<YOUR_KEYGEN_PRODUCT_API_TOKEN>"
 
# Create a new release
keygen new --version '1.0.0'
 
# Upload the artifact
keygen upload ./hello_world.tar.gz --platform 'docker' --release '1.0.0'
 
# Publish the release
keygen publish --release '1.0.0'

You can find your account ID in your settings page.

Downloading our Docker image

Now that we have our Docker image published, on our Dashboard, we can navigate to the Releases section and see our new v1.0.0 release. Since by default, Keygen only allows releases to be downloaded by licensed users, we'll need to create a new license.

(Typically, license creation would be performed using the API, tied to your purchasing flow, but today we'll be doing things manually.)

Create a policy

On your account Dashboard, the next thing we'll need to do, if you haven't already, is create a Policy. These control the different types of products you offer. For this example, we'll be creating a simple 1 year timed license.

In the Policies section, click New Policy and enter a descriptive name and then select a 1 year Duration and set the policy's Authentication Strategy to LICENSE. Everything else can be left blank, but feel free to customize. Finally, click Create Policy.

Create a license

Next, navigate on over to the Licenses section and create a new License using the policy. The license will inherit most of its rules from its policy, but feel free to give it a descriptive name to keep things organized. When you're ready, click Create License.

You'll use the license's Key to authenticate with Keygen's API.

Download our release

Finally, let's download our Docker image! We'll download our image tarball from Keygen's API, using our license key for authentication. Then we'll run the counterpart to the docker save command, docker load.

curl -sSLO https://get.keygen.sh/demo/1.0.0/hello_world.tar.gz \
-u "license:6F4FF7-AA8DC8-9B138D-EE76D6-E5BEF5-V3"
 
docker load < hello_world.tar.gz
# => Loaded image: keygen/hello_world:v1.0.0

Listing our images, we should now be able to see v1 of our hello_world image.

docker image ls
# => REPOSITORY TAG IMAGE ID CREATED SIZE
# keygen/hello_world v1.0.0 df58ca39ab02 About a minute ago 174MB

The image can then be run and utilized as you would normally.

docker run -p 49160:8080 -d keygen/hello_world:v1.0.0
# => Server is running on port 8080
 
curl http://localhost:49160
# => Hello, world!

In conclusion

I hope this article has helped shed some light on how to use Keygen to distribute private Docker images to your customers. We've covered creating a minimalistic Docker image, packing the Docker image into a tarball, and then finally distributing that image to our customers. Keygen's API will automatically assert that releases are only accessible by users with a valid license.

These same concepts of "packing" a Docker image and publishing the resulting tarball can be used to manage releases using other services as well, such as for hosting private Docker images on AWS S3. This is usually much simpler, operationally, than running and maintaining a full customer-facing Docker registry on your own infrastructure.

The immediate benefit of using Keygen over S3 is that you're able assert artifacts are only accessible to licensed users, making things like timed trials much easier to manage compared to managing IAM policies in S3 for each customer, or manually adding and removing customers from a private Docker Hub repository. *shudder*

After that, you can then go on to add licensing logic into your application itself, to support more fine-grained entitlement controls, such as activation limits, feature flags and more, by integrating with our software licensing API.

Until next time.


If you find any errors in my code, or if you can think of ways to improve things, ping me via Twitter.