# Deploying Clojure Like a Seasoned Hobbyist

This is a tutorial on how to set up a minimal Clojure app development environment with Postgres on DigitalOcean using Terraform. Although there will be room for further optimization and improvement in the implementation, this post focuses on providing the most straightforward template for setting up a project.

I prefer configuring deployments in declarative Terraform files over clicking through dashboards. This helps me to get back on track when I return to my abandoned side projects.

It's worth mentioning that I will be using a local terraform state, although I don't recommend it. Lately, I have been using Terraform Cloud for my projects. If you want to learn how to set up a Github CI with Terraform Cloud for this project, please let me know in the comments.

Most platforms like AWS, GCP, Azure, Linode, Fly, and Digitalocean support Terraform. The big three, AWS, GCP, and Azure, feel excessive for hobby projects. Additionally, managing virtual servers or utilizing Kubernetes is not something I want to deal with, so that crosses Linode off the list, leaving me with Fly and Digitalocean as the options. While I haven't tested Fly yet, DigitalOcean's app platform has become my go-to choice out of habit and because the development experience feels suitable.

## Requirements

We need [Terraform](https://developer.hashicorp.com/terraform/downloads), [Doctl](https://docs.digitalocean.com/reference/doctl/how-to/install/), [Docker](https://docs.docker.com/engine/install/), and [Docker Compose](https://docs.docker.com/compose/install/) installed on our development machine and a [DigitalOcean](https://www.digitalocean.com/) account with an [API token](https://docs.digitalocean.com/reference/api/create-personal-access-token/) with write permissions that we can use to authenticate ourselves from the command line.

You should be able to follow up on the examples without prior knowledge of Terraform. Still, if you're interested in learning more, the [official website](https://developer.hashicorp.com/terraform/intro) is an excellent place to start.

## Application

To get started, create the following folder structure for the project. Clojure-related code and required Docker files are in the `api` folder, where `docker-compose.yml` is for running the application locally with a Postgres database, and the DigitalOcean-related Terraform code will be in the `terraform` folder.

```bash
project-root
├── api
│   ├── deps.edn
│   ├── docker-compose.yml
│   ├── Dockerfile
│   └── src
│       └── main.clj
└── terraform
    ├── main.tf
    ├── output.tf
    └── setup.tf
```

### Clojure

First, let's start by defining the Clojure dependencies for the application in the `api/deps.edn`. We need `ring` dependencies for serving the API and `next.jdbc` and `postgresql` for the database access.

```clojure
{:paths ["src"]

 :deps {org.clojure/clojure {:mvn/version "1.11.0"}
        ring/ring-core {:mvn/version "1.6.3"}
        ring/ring-jetty-adapter {:mvn/version "1.6.3"}
        com.github.seancorfield/next.jdbc {:mvn/version "1.2.659"}
        org.postgresql/postgresql {:mvn/version "42.2.10"}}

 :aliases {:run {:main-opts ["-m" "main"]
                 :exec-fn   main/run!}}}
```

Next, create the code to serve the API, connect to the database, and make a database query in `api/src/main.clj`.

```clojure
(ns main
  (:require [ring.adapter.jetty :as jetty]
            [next.jdbc :as jdbc])
  (:gen-class))

(def port (Integer/parseInt (System/getenv "PORT")))

(def db {:dbtype "postgres"
         :jdbcUrl (System/getenv "JDBC_DATABASE_URL")})

(def ds (jdbc/get-datasource db))

(defn app [_request]
  (let [db-version (jdbc/execute! ds ["SELECT version()"])]
    {:status  200
     :headers {"Content-Type" "application/edn"}
     :body    (str db-version)}))

(defn run! [& _args]
  (jetty/run-jetty #'app {:port port}))
```

This is all of the application code. The next step is to create a Dockerfile and set up the local development environment to validate that the database connection and the API are working as expected when provided with the expected environment variables.

### Dockerfile

Let's keep the `api/Dockerfile` as simple as possible for now.

```bash
FROM clojure:openjdk-17-tools-deps-buster

WORKDIR app
COPY . .

RUN clojure -P

CMD clj -X:run
```

> In general it's better to create the Dockerfile in stages to avoid unnecessary build delay and minimize the image size. If you're interested in learning more about that let me know in the comments. You can also read more [Docker: Multi-Stage Builds](https://docs.docker.com/build/building/multi-stage/) for further information.

Finally, let's set up a `api/docker-compose.yml` file for mimicking our deployment environment to test that the API is working as expected.

```dockerfile
version: '3.1'

services:

  api:
    build: .
    environment:
      JDBC_DATABASE_URL: "jdbc:postgresql://postgres:5432/db?user=user&password=password"
      PORT: 8000
    ports:
      - 8000:8000

  postgres:
    image: postgres
    environment:
      POSTGRES_DB: db
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
    ports:
      - 5432:5432
```

### Validate Application Code

Start the API and the database with `docker-compose up` and `HTTP GET localhost:8000`.

```bash
❯ http localhost:8000
HTTP/1.1 200 OK
Content-Type: application/edn
Date: Mon, 30 Oct 2023 16:50:53 GMT
Server: Jetty(9.2.21.v20170120)
Transfer-Encoding: chunked

[{:version "PostgreSQL 14.2 (Debian 14.2-1.pgdg110+1) 
            on x86_64-pc-linux-gnu, compiled by gcc 
            (Debian 10.2.1-6) 10.2.1 20210110, 64-bit"}]
```

If you did get something similar to the above, we are good to continue forward. The next step is to set up the application to run in DigitalOcean.

> In the case you're wondering what's the `http` command. It is [httpie](https://httpie.io/cli).

## DigitalOcean and Terraform

The first thing we must do is to set up the DigitalOcean Terraform provider. This is analogous to adding a Clojure dependency in a `deps.edn` file. Open `terraform/setup.tf` and add the following content to it.

```go
terraform {
  required_providers {
    digitalocean = {
      source  = "digitalocean/digitalocean"
      version = "~> 2.0"
    }
  }
}

provider "digitalocean" {}
```

When a new provider is added, Terraform needs to be explicitly initialized with `terraform init`. After running the command, you should see the following message with additional output.

```bash
Terraform has been successfully initialized!
```

So far, we have downloaded the provider but have not yet configured the authentication. We'll get there in a moment.

### Project

Now it's time to set up the project to which we'll assign all our resources. Add the following content to `terraform/main.tf` and feel free to name your project however you wish.

```bash
resource "digitalocean_project" "project" {
  name        = "my-sample-project"
  description = "description"
  environment = "development"
  purpose     = "template-app"
}
```

If we try to apply the changes before configuring the API token, we should see something like this:

```bash
❯ terraform apply

...

digitalocean_project.project: Creating...
╷
│ Error: Error creating Project: POST https://api.digitalocean.com/v2/projects: 
|        401 (request "f2774cfc-66fc-4f34-98d9-0935f9dcd33d") 
|        Unable to authenticate you with digitalocean_project.project,
│        on main.tf line 1, in resource "digitalocean_project" "project":
│        1: resource "digitalocean_project" "project" {
│
╵
```

To apply the changes, we must authenticate ourselves with a `token`. There are a couple of different options on how to do this.

> [`token`](https://registry.terraform.io/providers/digitalocean/digitalocean/latest/docs#token) - (Required) This is the DO API token. Alternatively, this can also be specified using environment variables ordered by precedence:
> 
> * [`DIGITALOCEAN_TOKEN`](https://registry.terraform.io/providers/digitalocean/digitalocean/latest/docs#DIGITALOCEAN_TOKEN)
>     
> * [`DIGITALOCEAN_ACCESS_TOKEN`](https://registry.terraform.io/providers/digitalocean/digitalocean/latest/docs#DIGITALOCEAN_ACCESS_TOKEN)
>     
> 
> [DigitalOcean Terraform Provider](https://registry.terraform.io/providers/digitalocean/digitalocean/latest/docs)

I'll do this step by exporting my API token in an environment variable in the terminal.

```bash
❯ export DIGITALOCEAN_TOKEN=${YOUR_API_TOKEN}
```

After this, we should be able to create the project by running `terraform apply`.

```bash
❯ terraform apply

Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # digitalocean_project.project will be created
  + resource "digitalocean_project" "project" {
      + created_at  = (known after apply)
      + description = "description"
      + environment = "development"
      + id          = (known after apply)
      + is_default  = false
      + name        = "my-sample-project"
      + owner_id    = (known after apply)
      + owner_uuid  = (known after apply)
      + purpose     = "template-app"
      + resources   = (known after apply)
      + updated_at  = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

digitalocean_project.project: Creating...
digitalocean_project.project: Creation complete after 1s [id=22276173-77de-4e41-ab47-fae4a86ec414]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
```

And confirm that the project was created by logging in to the DigitalOcean dashboard.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1698853736510/172d417b-8394-47c0-bc63-78ea8cb4caf5.png align="center")

### Container Registry

The next step is to set up the container registry to publish the application image. Add the following to `terraform/main.tf`.

```go
 resource "digitalocean_container_registry" "app_registry" {
  name                   = "clojure-sample-app"
  subscription_tier_slug = "starter"
}
```

After applying the configuration, confirm that the registry was created. Let's do it with the `doctl` command line tool this time around.

```bash
❯ doctl registry get clojure-sample-app
Name                  Endpoint                                        Region slug
clojure-sample-app    registry.digitalocean.com/clojure-sample-app    blr1
```

Here, we need to pick up the endpoint value `clojure-sample-app` and use that to tag the application image. Add the `build` section to the `api/docker-compose.yml` file and run `docker-compose build`.

```yaml
services:
  api:
    build:
      context: .
      tags:
        - "registry.digitalocean.com/clojure-sample-app/dev"
    environment:
      JDBC_DATABASE_URL: "jdbc:postgresql://postgres:5432/db?user=user&password=password"
      PORT: 8000
    ports:
      - 8000:8000
```

After building, confirm that the image exists with the expected name.

```bash
❯ docker image ls | grep clojure-sample-app
registry.digitalocean.com/clojure-sample-app/dev   latest 20266c1dfcda   12 seconds ago   715MB
```

It looks like the build produced the expected outcome. Next, we must [log in](https://docs.digitalocean.com/products/container-registry/how-to/use-registry-docker-kubernetes/#docker-integration) to the DigitalOcean container registry to publish the Docker image.

```bash
❯ doctl registry login
Logging Docker in to registry.digitalocean.com
```

And then push the image to the registry.

```bash
❯ docker push registry.digitalocean.com/clojure-sample-app/dev
...
latest: digest: sha256:d8a5c01cd95ab5c0981c454866ec52203feba529f169e2da93cb6a99f3af5d88 size: 2840
```

Let's confirm with `doctl` that the image is available in DigitalOcean.

```bash
❯ doctl registry repository list-v2
Name    Latest Manifest                                                            Latest Tag    Tag Count    Manifest Count    Updated At
dev     sha256:d8a5c01cd95ab5c0981c454866ec52203feba529f169e2da93cb6a99f3af5d88    latest        1            1                 2023-11-01 16:07:59 +0000 UTC
```

At this point, we have created everything required for the app deployment. Let's do that next.

### Application

Create a `digitalocean_app resource` and update the `digitalocean_project` from before by adding the app to the resources. This way, DigitalOcean can group the resources (in this case, just the app) under the project.

Let's use the least expensive configuration for the dev environment by utilizing a `basic-xss` instance with a development database. You can modify these based on your needs. See the rest of the available configuration options from the [docs](https://registry.terraform.io/providers/digitalocean/digitalocean/latest/docs/resources/app#argument-reference).

```go
resource "digitalocean_project" "project" {
  ...
  # Add this line
  resources   = [digitalocean_app.app.urn]
}

resource "digitalocean_app" "app" {
  spec {
    name   = "sample-app"
    region = "ams"

    alert {
      rule = "DEPLOYMENT_FAILED"
    }

    service {
      name               = "api"
      instance_count     = 1
      instance_size_slug = "basic-xxs"

      image {
        registry_type = "DOCR"
        repository    = "dev"
        tag           = "latest"
        deploy_on_push {
          enabled = true
        }
      }

      env {
        key   = "JDBC_DATABASE_URL"
        value = "$${starter-db.JDBC_DATABASE_URL}"
      }

      source_dir = "api/"
      http_port  = 8000

      run_command = "clj -X:run"
    }

    database {
      name       = "starter-db"
      engine     = "PG"
      production = false
    }
  }
}
```

You probably noticed that we don't have a `PORT` variable for the application. This is because, in this case, we had configured it. Digitalocean would override it with the `http_port` variable.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1698857182445/16813ef2-1b72-48cc-bac1-918c59607c1a.png align="center")

The `image` block of the configuration ties the container registry we created earlier to the application. After, whenever we push a new image with the `dev` tag, the application will be redeployed.

### Validate

Finally, it's time to check if we did everything correctly. Let's fetch the live URL for our new application. We can do this with `doctl apps list`.

```bash
❯ doctl apps list -o json | jq ".[0].live_url"
"https://sample-app-xeoo8.ondigitalocean.app"
```

However, I prefer using the resource [available attributes](https://registry.terraform.io/providers/digitalocean/digitalocean/latest/docs/resources/app#attributes-reference) via [Terraform outputs](https://developer.hashicorp.com/terraform/language/values/outputs). These are usually declared in the `output.tf` file, but I'll skip this step for the example and leave it in `terraform/main.tf`. Add the following to the file and run `terraform apply` once more.

```bash
output "app_url" {
  value = digitalocean_app.app.live_url
}
```

Afterwards, the outputs can be viewed with `terraform output`

```bash
❯ terraform output
app_url = "https://sample-app-xeoo8.ondigitalocean.app"
```

Now, we are ready for the last step. Validate that we get the Postgres version as a result, as we did with the local setup.

```http
❯ http https://sample-app-xeoo8.ondigitalocean.app

HTTP/1.1 200 OK
CF-Cache-Status: MISS
CF-RAY: 81f5801c8898d92e-HEL
Connection: keep-alive
Content-Encoding: br
Content-Type: application/edn
Date: Wed, 01 Nov 2023 16:26:37 GMT
Last-Modified: Wed, 01 Nov 2023 16:26:36 GMT
Server: cloudflare
Set-Cookie: __cf_bm=SHREkz9ddLOs1EoTzuNj7DSPYfazEy_bpdi7y_Kb9BE-1698855996-0-AVgy3TNvUDITYonrci23K+dW1BHozbNCLeNX0FMHMpI3miRJBq8I4HlQE5nMA5YjoZ4dEXLatcef+AE+gjq4xeQ=; path=/; expires=Wed, 01-Nov-23 16:56:36 GMT; domain=.ondigitalocean.app; HttpOnly; Secure; SameSite=None
Transfer-Encoding: chunked
Vary: Accept-Encoding
cache-control: private
x-do-app-origin: 35eedf3b-e0a9-4376-863e-c5ba59ef5d6a
x-do-orig-status: 200

[{:version "PostgreSQL 12.16 on x86_64-pc-linux-gnu, 
            compiled by gcc, a 47816671df p 0b9793bd75, 64-bit"}]
```

Looks like everything is working as expected.

### Destroy

If you don't want to keep the application running, remember to destroy all of the created resources with `terraform destroy`. The expected monthly cost with this setup with the `basic-xxs` instance size and development database is around $12 per month, but the starter subscription tier of DOCR is free of charge.

You can read more about the pricing models from the DigitalOcean docs: [App platform](https://docs.digitalocean.com/products/app-platform/details/pricing/), [DOCR pricing](https://docs.digitalocean.com/products/container-registry/details/pricing/).

## Conclusion

I think that DigitalOcean is an excellent lightweight alternative for smaller projects. It does have all the expected features for general app development, and it can be extended with the add-ons found in the [marketplace](https://marketplace.digitalocean.com/). I have not yet used it for long-term projects since my side projects don't last long enough, but so far, I've been happy with it from the tinkerer's perspective. I would rather not spend my leisure coding time dealing with infrastructure.

Once again, thanks for reading, and I hope you found this helpful. Here's the accompanying GitHub [repository](https://github.com/tvaisanen/digitalocean-terraform-clojure-template/tree/blog-post-series-01).
