[{"content":"When talking to AWS from a Go program the easiest way is to use the AWS SDK. Say you want to list the S3 buckets. The program would look something like:\npackage main import ( \"github.com/aws/aws-sdk-go-v2/config\" \"github.com/aws/aws-sdk-go-v2/service/s3\" ) func main() { // ... cfg, err := config.LoadDefaultConfig(ctx) // ... client := s3.NewFromConfig(cfg) out, err := client.ListBuckets(ctx, \u0026s3.ListBucketsInput{}) // ... } You can find the full program here.\nNow, let’s build and run the program inside a container that has never been configured to connect to AWS:\n$ cd ~/github.com/go-monk/aws-config/list-s3-buckets $ GOOS=linux CGO_ENABLED=0 go build $ docker run --rm -it --mount type=bind,source=\".\",target=/tmp,readonly busybox sh # /tmp/list-s3-buckets list-s3-buckets: operation error S3: ListBuckets, resolve auth scheme: resolve endpoint: endpoint rule error, Invalid region: region was not a valid DNS name. You get this error because region is a required configuration value and it’s not set since there’s no configuration in the container. The default configuration sources that the SDK’s config.LoadDefaultConfig function searches are:\nenvironment variables, like AWS_REGION shared1 configuration and credentials files in ~/.aws/ You can also optionally pass additional configuration to the function as you can see below where the config.WithRegion function is used.\nLet’s look in more detail at all three configuration sources:\n// ... cfg, err := config.LoadDefaultConfig( context.Background(), config.WithRegion(\"us-east-1\")) // ... for i, source := range cfg.ConfigSources { var region string switch source := source.(type) { case config.LoadOptions: region = source.Region case config.EnvConfig: region = source.Region case config.SharedConfig: region = source.Region } fmt.Fprintf(writer, \"%d\\t%T\\t%s\\n\", i+1, source, region) } // ... You can find the full program here. Let’s run it:\n# AWS_REGION=eu-central-1 /tmp/list-config-sources SOURCE TYPE REGION 1 config.LoadOptions us-east-1 2 config.EnvConfig eu-central-1 3 config.SharedConfig us-east-2 In the table above you can see the three types of configuration sources and the region configuration value. The us-east-1 region is hardcoded inside the program by virtue of the config.WithRegion option, eu-central-1 comes from the AWS_REGION environment variable and us-east-2 from the default profile in the ~/.aws/config file:\n# cat ~/.aws/config [default] region=us-east-2 Play around with the programs, change them and change the config file and environment variables. It will help you to understand what’s going on.\nThey are called shared because they are used by all local AWS CLIs (like aws s3 ls) and SDKs (like the programs above). ↩︎\n","date":"2026-09-09","permalink":"https://gomonk.dev/posts/aws-config/","summary":"When talking to AWS from a Go program the easiest way is to use the AWS SDK. Say you want to list the S3 buckets. The program would look something like:\npackage main import ( \"github.com/aws/aws-sdk-go-v2/config\" \"github.com/aws/aws-sdk-go-v2/service/s3\" ) func main() { // ... cfg, err := config.LoadDefaultConfig(ctx) // ... client := s3.NewFromConfig(cfg) out, err := client.ListBuckets(ctx, \u0026s3.ListBucketsInput{}) // ... } You can find the full program here.\nNow, let’s build and run the program inside a container that has never been configured to connect to AWS:\n","title":"AWS configuration sources"},{"content":"GitHub keeps workflow run logs for 90 days by default, then deletes them. If you need to keep them longer - for audits, incident postmortems, or just peace of mind - you have to ship them somewhere else yourself. So I wrote workflow-logs-to-aws, a small Go CLI and GitHub Action that pushes job logs from a workflow run into AWS CloudWatch Logs.\nIt’s a plain Go program under the hood: no framework, just the go-github and AWS SDK v2 clients wired together.\nrunAttempts, err := ghc.WorkflowRunAttempts(ctx, runID) ... for _, runAttempt := range runAttempts { jobs, err := ghc.WorkflowJobs(ctx, runID, runAttempt) ... for _, job := range jobs { logs, err := ghc.DownloadJobLogs(ctx, *job.ID) ... err = cw.UploadJobLog(ctx, logs, *job.WorkflowName, runID, runAttempt, *job.ID, *job.Name, logGroup, retentionDays, replace) } } For each completed job it downloads the log, ensures a CloudWatch log group and stream exist, and writes the log lines as CloudWatch events, parsed from GitHub’s own timestamped log format. Run it as a standalone binary:\n$ go install github.com/go-monk/workflow-logs-to-aws@latest $ workflow-logs-to-aws -repository owner/repo 123456789 (123456789 is the workflow run ID, found at the end of a run’s URL: github.com/owner/repo/actions/runs/123456789.)\nor wire it into CI as an action, triggered after another workflow finishes:\non: workflow_run: workflows: [Some workflow] types: [completed] permissions: id-token: write actions: read jobs: push-logs: runs-on: ubuntu-latest steps: - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::123456789012:role/GitHubWorkflowLogsWriter aws-region: eu-central-1 - uses: go-monk/workflow-logs-to-aws@v0 with: retention-days: 30 Credentials come from whatever AWS action ran before it (no secrets baked in) - the recommended one is aws-actions/configure-aws-credentials, as used in the example above - and the action itself runs from a prebuilt image on GHCR, so there’s no build step on every CI run.\nI also added an -emf flag that, alongside the raw logs, emits CloudWatch EMF events per job - job count, failures, and duration - so you get workflow metrics and dashboards for free, without a separate metrics pipeline.\nIt’s a small tool doing one thing: taking logs that would otherwise vanish after 90 days and putting them somewhere durable and queryable, using infrastructure (CloudWatch, OIDC roles) you probably already have.\n","date":"2026-09-02","permalink":"https://gomonk.dev/posts/workflow-logs-to-aws/","summary":"GitHub keeps workflow run logs for 90 days by default, then deletes them. If you need to keep them longer - for audits, incident postmortems, or just peace of mind - you have to ship them somewhere else yourself. So I wrote workflow-logs-to-aws, a small Go CLI and GitHub Action that pushes job logs from a workflow run into AWS CloudWatch Logs.\nIt’s a plain Go program under the hood: no framework, just the go-github and AWS SDK v2 clients wired together.\n","title":"Push GitHub Actions logs to CloudWatch"},{"content":"A simple way to encipher, or encrypt, some data is by using the so-called shift cipher. We can do this in Go by going through the data byte by byte adding a key to each of the bytes.\nfunc Encipher(plaintext []byte, key byte) []byte { ciphertext := make([]byte, len(plaintext)) for i, b := range plaintext { ciphertext[i] = b + key } return ciphertext } In Go, bytes are equivalent to 8-bit numbers1, thus ranging from 0 to 255. Encrypting using the shift cipher actually means that each byte is shifted by key positions, wrapping around at 256. Bytes are often used to represent (encode) alphabet letters, so we are effectively shifting a letter’s position in the alphabet.\nTo decipher we need to do the same but in reverse, i.e. we subtract the key from each byte of the enciphered data.\nfunc Decipher(ciphertext []byte, key byte) []byte { return Encipher(ciphertext, -key) } This way Alice and Bob can exchange data in a somewhat secure manner. If Eve wants to learn what they are talking about she needs to know the encryption algorithm and the key. Let’s say she finds out they are using the shift cipher2 so she just needs to crack the key. The standard way to do this is called brute forcing, i.e. trying out all possibilities - in our case all possible keys. She also needs to know some bytes from the beginning of the “plaintext” data; this we call a crib.\nfunc Crack(ciphertext, crib []byte) (key byte, err error) { for guess := 0; guess \u003c 256; guess++ { result := Decipher(ciphertext[:len(crib)], byte(guess)) if bytes.Equal(result, crib) { return byte(guess), nil } } return 0, errors.New(\"no key found\") } If we call these functions (from within a main package stored under ./cmd) it looks like this:\n$ echo HAL | go run ./cmd/encipher IBM $ echo IBM | go run ./cmd/decipher HAL $ echo hello world | \\ go run ./cmd/encipher -key 10 | \\ go run ./cmd/crack -crib hell hello world See shift for all the code. Most of the ideas and code come from John Arundel’s book I started to read.\nThis article is a review of my older blog post.\nThe byte data type is actually an alias for uint8. ↩︎\nSometimes also called the Caesar cipher. ↩︎\n","date":"2026-09-02","permalink":"https://gomonk.dev/posts/shift-cipher-in-go/","summary":"A simple way to encipher, or encrypt, some data is by using the so-called shift cipher. We can do this in Go by going through the data byte by byte adding a key to each of the bytes.\nfunc Encipher(plaintext []byte, key byte) []byte { ciphertext := make([]byte, len(plaintext)) for i, b := range plaintext { ciphertext[i] = b + key } return ciphertext } In Go, bytes are equivalent to 8-bit numbers1, thus ranging from 0 to 255. Encrypting using the shift cipher actually means that each byte is shifted by key positions, wrapping around at 256. Bytes are often used to represent (encode) alphabet letters, so we are effectively shifting a letter’s position in the alphabet.\n","title":"Shift cipher in Go"},{"content":"Go is not the only language I use. But it’s my default language for CLI tools, infrastructure and services. I don’t use other languages unless there’s a good reason. Here goes why.\nSIMPLICITY and CLARITY. There is already enough technological and organizational complexity and confusion. Simpler and clearer systems are easier, cheaper, and more enjoyable to understand, maintain, and operate.\nLINGUISTIC STABILITY. The Go team has made a strong compatibility promise, so you don’t need to worry much about programs you write stopping to work as the language evolves. The language and standard library only add; they essentially never break what already worked.\nCROSS-COMPILATION to a SINGLE BINARY. Go makes it easy to build (statically linked) executable binaries for different platforms, which simplifies deployment enormously: GOOS=linux GOARCH=arm64 go build \u0026\u0026 deploy.sh\nSAFETY and SECURITY. Go is a relatively young language designed with modern safety and security concerns in mind. This is less true of languages created in a much earlier era when the security landscape was very different.\nOPTIMIZED for SDLC. Go improves the whole SDLC since it’s not just a language but also a toolchain (go mod tidy, go test, govulncheck) and an ecosystem of packages (libraries).\nAnd several of these advantages are even more important in the AI-assisted development.\n","date":"2026-07-13","permalink":"https://gomonk.dev/posts/default-to-go-for-infrastructure/","summary":"Go is not the only language I use. But it’s my default language for CLI tools, infrastructure and services. I don’t use other languages unless there’s a good reason. Here goes why.\nSIMPLICITY and CLARITY. There is already enough technological and organizational complexity and confusion. Simpler and clearer systems are easier, cheaper, and more enjoyable to understand, maintain, and operate.\nLINGUISTIC STABILITY. The Go team has made a strong compatibility promise, so you don’t need to worry much about programs you write stopping to work as the language evolves. The language and standard library only add; they essentially never break what already worked.\n","title":"Default to Go for infrastructure"},{"content":"Look, it’s simple. One of the main jobs of a DevOps1 engineer is to keep reducing the unnecessary complexity and bringing about clarity. And this is not simple.\nAny intelligent fool can make things bigger and more complex…\n– E.F. Schumacher\nTo be able to simplify, one needs to become very experienced, very senior. Here’s a pattern I’ve noticed that makes it difficult. I often take on (or I’m given) a task, that’s at least partly new to me. There’s a new tool, framework, platform, or programming language involved. And there’s usually a (self-imposed) deadline. So this approach, without the strikethroughs, seems reasonable:\nLearn the new stuff well. Build a prototype. Build the real solution. Refactor it. Refactor it again. Deploy. Now, the problem is that the first step tends to lose the “well” part and steps 3, 4, and 5 are often skipped. Sometimes because there’s no time. Sometimes because I’m lazy. One can mitigate this by asking for more time and by fighting the laziness2. But I should like to focus on a third option here: spending less time learning new stuff.\nI don’t want to stop learning, that would be a mistake. I just want to avoid learning too much new stuff that’s unrelated or duplicate to what I already know. And that’s why I prefer to invest deeply in concepts and in one good programming language and reuse (and learn more of) them wherever possible.\nFor me, that language is Go.\nAnd this site is about using Go to simplify DevOps and infrastructure work: writing CLI tools, building systems, and replacing unnecessary complexity with simple, maintainable software.\nHere, “DevOps” refers to the infrastructure and automation role. Depending on the company, it may also be called Platform Engineer, Cloud Engineer, SRE, or Sysadmin. ↩︎\nAlthough laziness can also be a virtue: https://bcantrill.dtrace.org/2026/04/12/the-peril-of-laziness-lost/. ↩︎\n","date":"2026-07-10","permalink":"https://gomonk.dev/posts/simplify-devops-with-go/","summary":"Look, it’s simple. One of the main jobs of a DevOps1 engineer is to keep reducing the unnecessary complexity and bringing about clarity. And this is not simple.\nAny intelligent fool can make things bigger and more complex…\n– E.F. Schumacher\nTo be able to simplify, one needs to become very experienced, very senior. Here’s a pattern I’ve noticed that makes it difficult. I often take on (or I’m given) a task, that’s at least partly new to me. There’s a new tool, framework, platform, or programming language involved. And there’s usually a (self-imposed) deadline. So this approach, without the strikethroughs, seems reasonable:\n","title":"Simplify DevOps with Go"}]