Zero Drama deployments with caprover

26 Jan 2025

How this blog is deployed

Looking for a reliable, low-maintenance blog setup that won't break the bank? Here's how I built a robust solution that costs just $5 a month. It's enterprise-grade features without the complexity or price tag.

Quick Stats:

  • đź’° Monthly Cost: $5 (Digital Ocean)
  • đź”§ Maintenance: Minimal (automated updates via cron)
  • 🚀 Deploy Time: Under 2 minutes (CapRover + Github Actions)
  • đź›  Tech Stack: Docker + Go

What it gets me

âś… Zero-downtime deployments (your content stays live, always)
âś… Automatic SSL certificate management (no more security headaches)
âś… One-click deployments (deploy new features in minutes, not hours)
âś… Simple rollbacks (instant recovery if something goes wrong)
âś… Total monthly cost less than a fancy coffee

Why this setup?

After years of wrestling with over-engineered solutions, I've learned that simplicity wins in the long run. While platforms like Kubernetes or full-blown cloud services are fantastic for enterprise needs, they're often overkill for personal projects or small-to-medium size business applications. What I love about this stack is that each component does exactly one thing, and does it well. CapRover handles container management without the complexity of Kubernetes, GitHub Actions gives us reliable CI/CD without expensive build servers, and Go creates tiny and efficient binaries that deploy in seconds. The best part? If something breaks, you can actually understand and fix it because the whole stack fits in your head and you can easily understand every layer.

Perfect for:

  • Small to mid-sized IT teams
  • Companies looking to reduce cloud costs
  • Teams that need enterprise features without enterprise complexity

Not ideal for:

  • Enterprise-scale deployments (10,000+ concurrent users)
  • Teams requiring extensive compliance certifications
  • Organizations with complex microservices architectures

The VM

I am running a very boring ubuntu LTS on a $5 digital ocean droplet. They are affordable, and you can easily replace this layer with any other cloud provider. You just need a static IP for routing your A records from the domain and you are pretty much good to go.

Maintenance of the VM is pretty straight forward with cron jobs and automatic updates turned on.

The platform

CapRover is my platform kubernetes alternative. You can think of it like a self hosted single node heroku. It is perfect for the developer who just has a few lightly used side projects to host.

A cool thing you can do with CapRover is 1-click deploys. These are little blueprints you can apply to your instance which will spin up databases, or other open source self hosted software. I use this for experiments that require development databases, redis instances.

To deploying your own code I recommend wrapping your app in a docker image then leveraging the github container registry (GHCR) and the docker image deployment in CapRover. Basically, you just instruct it to pull a specific image tag and it will manage a zero downtime roll out of the new version of the service.

CapRover has handy things built in like automatic https using let's encrypt, so you never need to worry about manual updates of tls certificates.

CI/CD

The Github Action for deploying is really simple, and at this point about 90% of the developers in the world can use it. Let’s go deep and look at exactly how it works. It’s so simple we can describe the whole thing in this post.

The boilerplate

name: deploy
run-name: "${github.actor} ${github.run_number} - deploy to captain rover"
on:
  push:
    branches:
      - main
jobs:
  build_and_deploy:

Our build starts with the basic instructions around creating a action, we have a name, a trigger, and one job build_and_deploy.

The setup

Once we are in our build_and_deploy job, we then do all the steps required to setup an envrionment for our code and build. This means logging in to the container registry, setting up docker and checking out the code.

    runs-on: ubuntu-latest
    steps:
      - name: Check out repository
        uses: actions/checkout@v2
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1
      - name: Login to Container Registry
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: "setup Go"
        uses: actions/setup-go@v2
        with:
          go-version: '>=1.21'

Note: There are a few variable replacements above for passing in the built in GITHUB_TOKEN secret for the github container registry login.

Lint and test

So far we have been mostly boilerplating things that every golang ci/cd workflow needs. Now that the environment is ready, we can use the following two steps for running a lint process and our tests.

Golangci-lint is a nice little additional golang linter which I have used at work, and really like to use on my own projects. It has sensible defaults and enforces rules for idiomatic looking golang projects.

Running tests is done next using the basic and simple go test -v ./... command. One of the go languages massive strengths is that you have a built in test runner that is pretty much just good enough.

      - name: golangci-lint
        uses: golangci/golangci-lint-action@v3
        with:
          version: v1.54
      - name: "test"
        run: |
          go test \-v ./...

Build

Assuming our build has made it this far, we should have a tested/linted bit of software, we can then build the binary and push the docker image.

      - name: build all the things with the current git sha
        run: |
          CGO_ENABLED=0 go build -ldflags "-X main.GitCommit=$(git rev-parse --short HEAD)" -o build/ ./...

For the build command, I'm also assigning the GitCommit hash to a constant in the code so that we can print out a log with the git commit. Including the current commit in the code is super handy for tracking down changes between deployments. When you are searching very old logs you can then correlate older code releases with the log event. The bit which is doing this assignment below is the -ldflags "-X main.GitCommit=... part.

You can then use code like the following listing to expose that git hash

package main
// GitCommit will contain the output of $(git rev-parse \--short HEAD)  
const GitCommit string

func main() {
    println("Hello!", GitCommit)
}

Deploy

A three step process is taken for deploying the docker image. First is generating the image url using the repo owner, name and the first 6 characters of the hash. Second is to do the docker build and push the image with the relevant tag. Finally we make an API call to caprover for triggering a deploy.

      - name: Preset Image Name
        run: echo "IMAGE\_URL=$(echo ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:$(echo ${{ github.sha }} | cut \-c1-7) | tr '\[:upper:\]' '\[:lower:\]')" >> $GITHUB_ENV
      - name: Build and push Docker Image
        uses: docker/build-push-action@v4
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ env.IMAGE_URL }}
      - name: Deploy Image to CapRrover
        uses: caprover/deploy-from-github@4f2b50c37be9f3f325c67b16660e321395841040
        with:
          server: "${{ vars.CAPROVA_INSTANCE }}"
          app: "${{ vars.APP_NAME }}"

          token: "${{ secrets.DEPLOY_TOKEN }}"

          image: "${{ env.IMAGE_URL }}"

Generating the image name When you append to the $GITHUB_ENV file, then the environment variables in there will be available to subsequent steps in the file. This is how we set the IMAGE_URL above, by using echo and >>. A bit of bash foo is used to turn the github.sha into a shorter version and converting the upper case letters to lowercase.

Building the docker image The dockerfile is incredibly simple for this blog, it simply copies the built binary into a distroless base. Behold the crazy simple 5 line docker file.

FROM gcr.io/distroless/static

WORKDIR /

COPY build/server /

EXPOSE 80

ENTRYPOINT ["./server"]

The private docker image is then pushed to CapRover, and our deployment is ready to roll out on the server.

Pushing the image to prod

CapRover has an API which can be used to trigger new deployments. Using a helpful github action I can call this API to kick things off. All going well, the new deployment should happen within seconds of it being triggered.

What’s good

It’s simple and fast. Being simple isn’t always easy when you want some of the features like docker deployment and automatic https and zero downtime deployments. The approach is fast enough for my current stage and scale. Sure – you wouldn’t want this if you are a growth start up with millions of customers. This is a simple AND fast solution that works really well for a personal site.

Zero maintenance, it just runs in the background. I have been very pleased with the amount of admin overhead on this solution. Once it is all setup it’s very easy to keep running, and the deployment pattern is simple enough that I know I can get any dockerized solution released under a domain in less than 30 minutes effort.

I can understand how it all works because I built it from the operating system up. People severely underrate the ability to understand how software gets in to production. In big kubernetes deployments you often have whole teams responsible for the operations. If you are using something like heroku or vercel, good luck understanding exactly how your software is running and the environment it is deployed in.

What’s next

Go, templ and htmx have been 0 regret choices for running this application and I plan to share a bit more about how it works soon. It is because of these choices I can have teeny tiny docker images and super fast cache-able websites. I think this platform will last me for the next “100 years” still, and I’m excited to see it evolve as a place where I experiment and build something cool

Subscribe to my Newsletter

Want to know more? Put your email in the box for updates on my blog posts and projects. Emails sent a maximum of twice a month.