5 min read

Hosting quickly 1: Setting up Terraform, Github, and 1Password

Hosting quickly 1: Setting up Terraform, Github, and 1Password
The Cochrane shuttle entering transwarp, or whatever.

This is the first article in a series called "Hosting quickly". I want to launch Rust apps quickly and often, and I want to have stable, common infrastructure for all of them.

We'll call this Cochrane[1]. The idea is simple enough: create a template repository for quickly booting up a new Rust web project. It'll include the server, database, queues and background workers, blob store and image serving, and metrics and alerts. My first pass will all rely on SaaS companies with free tiers to minimise my ops load. However, all the technologies are open source and I intend to write an alternative Terraform setup for hosting on any k8s instance.

Feel free to skip to the repo.

Here's the plan for this post:

Run everything from one repository

First, we'll set up a Git monorepo managed through Just. For this first post, we'll only require a few files and directories:

.
├── .git/...
├── .github/
│   └── workflows/
│       └── infra.yaml
├── infra/
│   ├── justfile
│   ├── main.tf
│   └── terraform.tfvars
└── justfile

In future posts, we'll add things like server/, web/, and worker/, a Cargo.toml, and presumably many other important files and projects.

Manage infrastructure using Terraform

First, let's add the Github repository itself through Terraform. While this repository is intended as a template[2], it's also going to be an example and testing repo.

To do this, I have to register a new Github personal access token. I'll call it cochrane. For now, I'll give this token full access to everything on the cochrane repository. Let's put that in a terraform.tfvars file (make sure to add it to the Gitignore!) and call it github_token. We'll also add a github_owner for and github_name.

Then, we add the repository config to our main.tf:

variable "github_token" {
  type = string
  description = "Your Github OAuth token"
}

variable "github_owner" {
  type = string
  description = "The owner of the Github repository"
}

variable "github_name" {
  type = string
  description = "The name of the Github repository"
}

terraform {
  required_providers {
    github = {
      source  = "integrations/github"
      version = "~> 5.0"
    }
  }
}

provider "github" {
  token = var.github_token
  owner = var.github_owner
}

resource "github_repository" "repository" {
  name = var.github_name
  visibility = "private"
}

We'll separate this and clean it up when it starts risking getting messy. We'll need to setup the Terraform state:

$ terraform init
$ terraform import github_repository.repository cochrane

And then for future usage we'll add the required commands to our infra/justfile, so that we can run things like just infra/plan.

plan:
    terraform plan -var-file=terraform.tfvars

apply:
    terraform apply -var-file=terraform.tfvars

Store Terraform secrets and state in a 1Password vault

This might be a bit unorthodox, but I don't like using more than one tool to do the same thing, and this entire project is about building something that works for me. I'll be storing the terraform.tfvars values in 1Password and using its op inject tool to load it. Additionally, we'll be using op item and op read -o to load the terraform.tfstate file.

Secrets

Let's start with the easy part: secrets. This is obviously a quick op inject away. First, I've moved the Github token into a new Cochrane vault. Then we'll create a terraform.tfvars.tpl file with our 1Password secrets referenced:

github_owner = "martijnarts"
github_name = "cochrane"
github_token = "op://cochrane/github/token"

I could now run op inject directly, but I don't want to worry about running this every time I update my secrets. Instead, let's modify the justfile[3]!

inject-tfvars:
    op inject -i ./terraform.tfvars.tpl -o terraform.tfvars

plan: inject-tfvars
    terraform plan -var-file=terraform.tfvars

apply: inject-tfvars
    terraform apply -var-file=terraform.tfvars

Now Just will automatically inject the tfvars file whenever we try to plan or apply our changes.

State

Now for the other part: we want to store our Terraform state inside 1Password also. We'll reuse the Cochrane vault and create a new item called terraform_state with the current terraform.tfstate file in it:

$ op document create infra/terraform.tfstate --vault cochrane --title "terraform_tfstate"

Now we just need to update the Justfile again:

inject-tfvars:
    op inject -i ./terraform.tfvars.tpl -o terraform.tfvars

load-tfstate:
    op document get --vault cochrane -o ./terraform.tfstate

store-tfstate:
    op document edit --vault cochrane terraform_tfstate ./terraform.tfstate

plan: inject-tfvars load-tfstate
    terraform plan -var-file=terraform.tfvars

apply: inject-tfvars load-tfstate && store-tfstate
    terraform apply -var-file=terraform.tfvars

This works really nicely, especially Just's && syntax for running other tasks before and after any given task.

Manage Terraform from Github

Access 1Password from Github Actions

Now let's try running at least just infra/plan in Github Actions. We'll use a Service Account, so let's create that first, store it in the vault and add it to the terraform.tfvars.tpl so we can have Terraform store the secret in the Github repository.

gha_1p_service_account = "op://cochrane/gha_1p_service_account/token"
resource "github_actions_secret" "op_service_account" {
  repository = var.github_name

  secret_name = "OP_SERVICE_ACCOUNT"
  plaintext_value = var.gha_op_service_account
}

resource "github_actions_secret" "op_service_account" {
  repository = var.github_name

  secret_name = "OP_SERVICE_ACCOUNT"
  plaintext_value = var.gha_op_service_account
}

Running just infra/apply will store the secret appropriately!

Planning in Actions

I'll leave a lot of this as an exercise to the reader, cause there's a lot of code. It's not very complex though. First, we'll need an additional just command to save a plan to a file that we'll read out for a summary:

save-plan filename="tfplan": init-terraform inject-tfvars load-tfstate
    terraform plan -var-file=terraform.tfvars -out={{filename}}

Don't you love how simple that turns out with Just?

name: Apply infra changes
permissions:
  issues: write
on:
  pull_request:
    paths:
      - "infra/**"
      - ".github/workflows/infra.yaml"

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      # omitting:
      # - actions/checkout
      # - 1password/install-cli-action
      # - extractions/setup-just
      # - hashicorp/setup-terraform
      # - kishaningithub/setup-tf-summarize

      - name: Run Terraform Plan
        env:
          OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT }}
        run: just --dotenv-filename .just-env infra/save-plan

      - name: summary in table format
        working-directory: ./infra
        run: |
          rm -rf tf-summarize-table-output.md
          echo "## tf-summarize output:" > tf-summarize-table-output.md
          echo "" >> tf-summarize-table-output.md
          terraform show -json tfplan | tf-summarize -md >> tf-summarize-table-output.md

      - name: Find Comment
        uses: peter-evans/find-comment@v2
        id: fc
        with:
          issue-number: ${{ github.event.pull_request.number }}
          comment-author: "github-actions[bot]"
          body-includes: tf-summarize output

      - name: Create comment with terraform plan summary
        uses: peter-evans/create-or-update-comment@v2
        with:
          comment-id: ${{ steps.fc.outputs.comment-id }}
          issue-number: ${{ github.event.pull_request.number }}
          body-file: "./infra/tf-summarize-table-output.md"
          edit-mode: replace

This works really nicely, giving a brief summary of the full plan in a PR comment, and updating the summary whenever you commit a change.

Theoretically, we might also want to apply with Github Actions. However, I think that's still a bit too risky for now. Maybe when I've put some more thought into it.

Stay tuned for the application

Now that we have the infrastructure set up, we'll want to run a backend and frontend. Next time, we'll set up a really simple Dioxus frontend and Axum backend and deploy them to Fly.io.

You can find Cochrane here.


  1. This is in reference to the Cochrane 04 shuttle from Voyager that broke the transwarp barrier in an altogether absurd episode. ↩︎

  2. In the actual repository, I've also added a setup step to the justfile which actually takes you through the creation of the repo and initialisation and importing of the repository. I'm not putting that here, but you can go check it out if you want. It does mean that I have some code in here that might not make sense when setting up for one specific repository. ↩︎

  3. Now ideally, we would have this work like Make, where inject-tfvars only gets run when the .tpl file actually got changed. There's an open issue for that on Just, however it doesn't seem to be prioritized currently. It's fast enough anyway 🤷🏻‍♂️ ↩︎