Automating Deployment with Docker, Containers and Github Actions

This is a continuation of the previous series of posts here:

Part 1 Part 2 Part 3 Part 4

That being said, you certainly do not need to do all the previous parts for this post to be beneficial. This part can apply to any sort of project but may require some tweaks if you are using another language. With that said...

Time to make this easy to deploy

We put in all the time and effort to build this thing, so why not take a little bit more time and effort to make it easy to deploy? In this post, we are going to turn this project into a 'distroless' Docker container, make sure it works locally, then create a pipeline that ensures all commits to main are replicated to our Docker image in the GitHub Container Registry. The end result is that you can create a Docker Compose file on any type of server and have your container running locally within 30 seconds. Sound good? Let's get going.

Let's start by creating a new Dockerfile.

1touch Dockerfile

Now copy the following into the Dockerfile:

 1# ---- build stage ----
 2FROM golang:1.24.2 AS build
 3WORKDIR /src
 4
 5COPY go.mod go.sum ./
 6RUN go mod download
 7
 8COPY . .
 9
10ARG TARGETOS
11ARG TARGETARCH
12
13RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
14  go build -trimpath -ldflags="-s -w" -o /out/blogo ./cmd/server
15
16# ---- runtime stage ----
17FROM gcr.io/distroless/base-debian12:nonroot
18WORKDIR /app
19
20COPY --from=build /out/blogo /app/blogo
21
22
23# No need to copy ui/static or ui/templates, as they are embedded in the binary
24
25# Optional: bake default content too (handy for first run)
26COPY content /app/content
27
28EXPOSE 3999
29USER nonroot:nonroot
30
31ENTRYPOINT ["/app/blogo"]

Let's explain what this does... The Dockerfile has two stages:

  1. Build Stage
  • Uses the official Go image to compile your Go app.
  • Sets up a working directory.
  • Downloads dependencies (go mod download).
  • Copies your code into the container.
  • Builds your Go app for the target OS and architecture, outputting a binary called 'blogo'.
  1. Runtime Stage
  • Uses a minimal, secure “distroless” image (no shell or package manager).
  • Sets up a working directory.
  • Copies the compiled blogo binary from the build stage.
  • Optionally copies default content for the app.
  • Exposes port 3999 for your app.
  • Runs the app as a non-root user for security.
  • Starts the app with the ENTRYPOINT.

At this point, we are able to build a container.

Let's now create a docker-compose.yaml file that makes it easy for us to spin up the container and test that it is working.

1touch docker-compose.yaml

Paste this text into the docker-compose.yaml file

1services:
2  blog:
3    build: .
4    container_name: blog
5    restart: unless-stopped
6    ports:
7      - "5040:3999"

This Docker Compose file does the following:

  • Creates a new service called blog
  • Runs the build command
  • Ensures that the blog will restart unless we stop it
  • Maps port 5040 on the outside to 3999 on the inside of the container

It is worth noting that you can also set this to 3999:3999 as well. You would need to ensure you don't fire up your development server while your container is running. Ultimately, do whatever works best for you.

Let's also create a .dockerignore file. This will ensure our images are smaller and keep out the junk.

1touch .dockerignore

Add the following (make it apply to your project):

 1# Git
 2.git
 3.gitignore
 4
 5# OS junk
 6.DS_Store
 7Thumbs.db
 8
 9# Editors
10.vscode
11.idea
12
13# Go build artifacts
14bin/
15dist/
16*.exe
17*.out
18*.test
19
20# Air live reload
21.air.toml
22tmp/
23
24# Logs
25*.log
26
27# Node (if present anywhere)
28node_modules/
29
30# Docker leftovers
31docker-compose.override.yml
32
33# Env / secrets
34.env
35.env.*

With both the Dockerfile and the docker-compose.yaml in place, let's try to build the container (note this assumes you have Docker installed on your system). Run the following commands:

1docker build -t blog:local . #this may take a bit
2docker compose up -d # start the compose file and disconnect

If everything functioned properly, we should have a working container. If your server is running on the same port defined in your docker-compose.yaml, you need to stop it. To see if your server is running, run the following command:

1docker ps -a # should show all running containers (may require sudo)

Github Actions

At this point, we should push our changes to the repo. The end goal here is to get all changes that we make to the blog to be reflected in our container image. There are a few ways to do this, but one common way is to use GitHub Actions.

Create a .github directory at the root of your project.

1mkdir -p .github .github/workflows
2touch .github/workflows/publish.yml

Copy the following text into that yaml file

 1name: Build & Push Image to GHCR
 2
 3on:
 4  push:
 5    branches: ["main"]
 6
 7permissions:
 8  contents: read
 9  packages: write
10
11jobs:
12  build-and-push:
13    runs-on: ubuntu-latest
14    steps:
15      - name: Checkout
16        uses: actions/checkout@v4
17
18      - name: Set up QEMU
19        uses: docker/setup-qemu-action@v3
20
21      - name: Set up Buildx
22        uses: docker/setup-buildx-action@v3
23
24      - name: Log in to GHCR
25        uses: docker/login-action@v3
26        with:
27          registry: ghcr.io
28          username: ${{ github.actor }}
29          password: ${{ secrets.GITHUB_TOKEN }}
30
31      - name: Build and push (multi-arch)
32        uses: docker/build-push-action@v6
33        with:
34          context: .
35          file: Dockerfile
36          push: true
37          platforms: linux/amd64,linux/arm64
38          tags: |
39            ghcr.io/${{ github.repository }}:latest
40            ghcr.io/${{ github.repository }}:${{ github.sha }}
41

Now let's review the publish.yml file and what it does:

  1. Every time you push code into the main branch, it will run.
  2. It will check out your code.
  3. Sets up the build tools needed for Docker images for all sorts of builds.
  4. Logs into GitHub Container Registry using your GitHub credentials.
  5. Builds your Docker image for multiple platforms.
  6. Pushes the built image to GHCR with two tags: latest and a unique tag for each commit.

Commit these changes up to GitHub and check the repo. You should see a green light near the title of the repo. This will show the status of the GitHub Action and if you ran into any errors. If you were successful, you should now have your container in the GitHub Container Registry.

You can now change your local docker-compose.yml file to reflect it being in the container registry:

1services:
2  blog:
3    image: ghcr.io/youraccount/blog:latest
4    ports:
5      - "5040:5040"
6    restart: unless-stopped

You should now be able to deploy this project on whatever server you want, as long as it has Docker and Compose on it. Simply SSH into the server, create a new docker-compose.yml file, and add the contents above. Bring up the stack and you should be off to the races.

I hope you enjoyed!