This is a follow-up to a previous article describing deployment options for http4k-based web apps. With the wind-down of Heroku's free tier, I wanted to walk through the process of setting up dokku, an open-source, self-hosted application coordinator in the spirit of Heroku.

Perhaps you followed my previous tutorial on deploying http4k-based web apps, and chose Heroku as the deployment target, especially due to its free tier. Unfortunately, in November 2022, Heroku pulled the plug on its free tier, leaving many users scrambling to find alternatives.

The point of that earlier article was to show how easy it is to switch between Heroku, AWS Lambda, or a self-hosted VPN. So you could choose any one of those options, and not be burdened with extensive changes to your code. In fact, the only code change required was a one-liner to provide an AWS Lambda-http4k "gateway." With Heroku gone, however, it is definitely worth learning about dokku, which I believe is a superior option to DIY self-hosting, and does bring some of the Heroku magic, like super-simple continuous deployment, to your own hosted cloud server. And like the other deployment options, it's very easy to set up with almost no code change necessary.

The official dokku docs should be your primary resource for configuring and deploying dokku itself, and then your application on top. However, I'll try to provide an overview and a walkthrough of the process I used.

Dokku will run on any Linux-based cloud server -- so it is up to you whether to get one from AWS, Azure, Digital Ocean, Linode, or any of the thousands of other proivders out there. I went with Scaleway, partly because it offers some very inexpensive servers, setup is easier (in my opinion) than any of the 3 "big" providers, and due to its API. We'll talk about that near the end. But the cloud you choose is totally up to you.

While this is not a security seminar by any means, and you should definitely refer to better resources for preparing and hardening a Linux server, some basic commands upon startup include:

# update all your apt-get sources
sudo apt-get update
sudo apt-get upgrade
sudo apt-get dist-upgrade

# create an alternative root user
adduser <new root username> (and enter password)
usermod -aG sudo <username>
su <username>

cd /opt/jdk
tar -xvf <filename>
sudo nano /etc/profile.d/java_home.sh
# edit the file to add JAVA_HOME to the PATH
JAVA_HOME=”/opt/jdk/<path to JDK you just unpacked>PATH=$PATH:$JAVA_HOME/bin”
# save & exit nano

# create mountable directories
mkdir /opt/storagemount
mkdir /opt/storagemount/<app name>

The final step in that script is important: dokku will run your application in a Docker container, therefore any files (like configuration files) that will be read by, or written to, the application must be in the mounted shared folder. I made the mistake of placing the files directly on the server's file system, these files are not seen by the container and will never be read.

Again, you should refer to dokku's docs for setup, but some basic commands include

# install dokku -- always check the home page for the latest version
wget https://raw.githubusercontent.com/dokku/dokku/v0.29.3/bootstrap.sh
sudo DOKKU_TAG=v0.29.3 bash bootstrap.sh

# install the lets-encrypt plugin for automatic setup of free TLS certs
sudo dokku plugin:install https://github.com/dokku/dokku-letsencrypt.git

# setup private keys
ssh-keygen  # creates key named id_rsa in user's /home/<username>/.ssh
# register key with dokku as admin
sudo dokku ssh-keys:add dokku-admin-<username> /home/<username>/.ssh/id_rsa.pub
# sslip.io is a cool service that allows you to refer to an IP address like a full domain
sudo dokku domains:set-global <ip address>.sslip.io

# upload private key from git provider (GitHub, GitLab, sr.ht, Bitbucket, etc)
# obtaining private key depends on provider
# upload to /home/<username>/.ssh/
eval `ssh-agent`
ssh-add /home/andyg/.ssh/<key you just uploaded>

That last step is important for creating a CI pipeline. dokku needs authorization to git pull when you make a new commit, that private key provides the authorization.

Now we can actually set up the app in dokku. But first, upload any shared files to the mounted shared directory you create above:

dokku apps:create <app name>
# you have to specify the gradle task for building your app -- I use shadowjar so the task name is shadowJar, it might just be "build"
dokku config:set <app name> GRADLE_TASK="shadowJar"
# share the mounted folder you created earlier, and set its location inside the container's file system 
dokku storage:mount <app name> /opt/storagemount/<app name>:/app/storage
# setup any environment variables
dokku config:set <app name> HOPLITE_FILENAME="/app/storage/config_cjl_dokku.yaml"    

You'll notice in the last step, we create an environment varible which points to a YAML-configuration file. In the earlier aarticle, we discuss how Heroku and AWS do not allow us to include additional files outside the executable or the classpath, so we used long JSON strings to define each of the individual variables. Hoplite is the package that offers multiple options for defining configurations, making it easy to accept JSON strings or YAML files (or other options). Here with dokku, we do have access to the file system (although we have to specifically mount a folder to be shared between host and container), so we are able to define all individual parameters inside a single configuration file.

At this point we have installed dokku, set up an application, uploaded and prepared the configuration file -- but we just haven't uploaded the app itself yet. This is where dokku shines, like Heroku did, creating an automatic CI pipeline is almost automatic. Every new commit will trigger a new build and deploy on the server.

To get this to work, you need to set the dokku server as a "remote" to your git repo. However you probably already have GitHub or another cloud service as the remote. Fortunately we can setup multiple remotes, however this will also require multiple pushes (of course I'm not a git expert, but I was unable to get a simple push to go to both remotes).

You will need to download the server's private key to your local dev machine and register it via ssh-agent.

eval `ssh-agent`
ssh-add -k <path to id_rsa on local machine>\\id_rsa_dokku

git remote add dokku dokku@<ipaddress>.sslip.io:<app name> 
git push dokku <branch name>

In that git push window, you should see an entire build taking place. Obviously if you see errors, you'll have to diagnose and fix them. One suggestion is to make sure your gradle setup uses gradlew (the Gradle wrapper). I discuss this in the earlier article, but gradlew ensures that every build will use the same, specific Gradle version no matter where that build is actually happening. This eliminates builds that work locally but fail remotely due to Gadle version differences. There are plenty of other reasons a build may go bad, but using gradlew helps eliminate one common source of errors.

When the build is complete, our application should be running using the sslip.io URL:

http://<dokku app name>.<ip address>.sslip.io

But we want a real URL! And of course we want a secure https connection. Basically, we need to go to our DNS provider, which may be the domain registrar, or a dedicated DNS service like DNSimple or CloudFlare or BunnyDNS. Most likely, you want to set up a new CNAME record to assign a friendly URL (like myapp.mycompany.com) to the full DNS of your cloud server (which may be like ec2-127-254-98-123.compute-1.amazonaws.com). Then we update dokku to work with the new URL:

dokku domains:add <app name> <friendly URL>  # test that app is available at new URL
# setup Let's Encrypt with an email address so it can create TLS certificates for our new URL
dokku config:set --no-restart <app name> DOKKU_LETSENCRYPT_EMAIL=your@email.tld
dokku letsencrypt:enable <app name>
# test that app is available at secure URL

And that's about it! At this point, the application should be reachable directly with an IP address, or with the IP address used within a sslip.io URL, by using the cloud server's DNS, or finally, by your freindly URL.

Scaleway

Earlier I mentioned that I chose Scaleway as my cloud provider for this project. One key feature I liked was Scaleway's simple API for powering the server on and off. The website for this project, obviously, requires 24/7 uptime. But it is hosted elsewhere at a reliable provider. The application that was running on Heroku, and now dokku, only gets launched a few times per day and generally takes only 2-5 minutes to run (it makes a number of HTTP calls, so the total time is usually determined by how fast the external servers respond).

The point being, I have no need to keep this server up and running 24/7. With Scaleway's API, I can use a cron service to send a series of specific calls:

  1. Power on server
  2. (10 minutes later) Launch application script
  3. (5-10 minutes later) Verify successful result -- log any errors
  4. (5 minutes later) Power off server

Beisdes not having an idle server open to any internet intruders out there, I'm only paying for about 30 minutes of use for each launch, and that is being conservative on startup and runtime. Why pay for 24 hours of use when I also need 1 per day? I am sure other cloud providers offer a similar API however I have found Scaleway's easy to learn and use and a great way to keep costs down for an intermittent use case.