π³ How to Manage Remote Docker Containers Using Go SDK and SSH Tunnel
βοΈ Introduction
In my previous post, I compared several Go web frameworks for developing a budget planning application. I ended up building a simple version for myself, and realized I needed to deploy it somehow. Previously, I deployed my personal applications in different ways:
Terraform - enterprise-grade infrastructure as code; good for work, but overkill for personal projects.
GitHub Actions - overkill for personal projects that don't require CI/CD.
Manual deployment over SSH - suitable for small personal projects but not scalable, as it requires repeated server setup and dependency installation.
For my budget tracking application, I had been using manual SSH deployments. However, I decided to explore automation options. In this article, I will explain how to control Docker on a remote machine via Go SDK and SSH, without exposing the Docker API externally.
π Local Docker Go API
Docker provides an SDK that allows you to control it via the Go programming language. Before we explore controlling a remote Docker instance, letβs first see how to control Docker locally using the Go SDK.
1package main
2
3import (
4 "context"
5 "io"
6 "log"
7 "os"
8
9 "github.com/docker/docker/api/types/container"
10 "github.com/docker/docker/api/types/image"
11 "github.com/docker/docker/client"
12)
13
14func pullImage(cli *client.Client, imageName string) error {
15 log.Printf("pulling the image: image = %s\n", imageName)
16 reader, err := cli.ImagePull(context.Background(), imageName, image.PullOptions{})
17 if err != nil {
18 return err
19 }
20 defer reader.Close()
21
22 io.Copy(os.Stdout, reader)
23 return nil
24}
25
26func startContainer(cli *client.Client, imageName string) (string, error) {
27 log.Printf("creating a container: image = %s\n", imageName)
28 resp, err := cli.ContainerCreate(context.Background(), &container.Config{
29 Image: imageName,
30 }, nil, nil, nil, "")
31 if err != nil {
32 return "", err
33 }
34
35 log.Println("Starting the container...")
36 if err := cli.ContainerStart(context.Background(), resp.ID, container.StartOptions{}); err != nil {
37 return "", err
38 }
39
40 return resp.ID, nil
41}
42
43func main() {
44 cli, err := client.NewClientWithOpts(client.FromEnv)
45 if err != nil {
46 log.Fatal(err)
47 }
48 cli.NegotiateAPIVersion(context.Background())
49
50 imageName := "hello-world:latest"
51
52 if err := pullImage(cli, imageName); err != nil {
53 log.Fatal(err)
54 }
55
56 containerID, err := startContainer(cli, imageName)
57 if err != nil {
58 log.Fatal(err)
59 }
60
61 log.Printf("container started: containerID = %s\n", containerID)
62}
The code above runs a Docker container with the Hello World image on the local machine. However, this code only manages Docker on the local machine and cannot handle remote Docker instances.
π§ Docker API Access
There are two ways to access the Docker API:
Docker REST API - Requires explicit enabling, which allows managing Docker remotely.
Docker Socket API - Enabled by default but not accessible remotely.
The first method requires extra configuration and poses a security risk by adding an additional attack vector to the server. Given that I already have SSH configured on my server, there is a third way to access Docker:
Docker Socket API via SSH tunnel - No additional Docker configuration required; just use an SSH tunnel to connect to the Docker socket.
π Remote Docker Go API over SSH Tunnel
To access Docker on a remote machine where SSH is already configured, you just need to create a tunnel to the Docker socket and manage Docker as if it were a local instance using the Go SDK.
1package main
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "log"
8 "net"
9 "os"
10
11 "github.com/docker/docker/api/types/container"
12 "github.com/docker/docker/api/types/image"
13 "github.com/docker/docker/client"
14 "golang.org/x/crypto/ssh"
15)
16
17func createSSHTunnel(user, privateKey string, serverAddr, localAddr string) error {
18 signer, err := ssh.ParsePrivateKey([]byte(privateKey))
19 if err != nil {
20 return fmt.Errorf("failed to parse private key: %w", err)
21 }
22
23 sshConfig := &ssh.ClientConfig{
24 User: user,
25 Auth: []ssh.AuthMethod{
26 ssh.PublicKeys(signer),
27 },
28 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
29 }
30
31 sshConn, err := ssh.Dial("tcp", serverAddr, sshConfig)
32 if err != nil {
33 return fmt.Errorf("failed to establish SSH connection: %w", err)
34 }
35
36 listener, err := net.Listen("tcp", localAddr)
37 if err != nil {
38 return fmt.Errorf("failed to listen on local address: %w", err)
39 }
40
41 go func() {
42 for {
43 localConn, err := listener.Accept()
44 if err != nil {
45 log.Printf("failed to accept local connection: %s", err)
46 continue
47 }
48
49 remoteConn, err := sshConn.Dial("unix", "/var/run/docker.sock")
50 if err != nil {
51 log.Printf("failed to dial remote Docker socket: %s", err)
52 localConn.Close()
53 continue
54 }
55
56 go func() {
57 defer localConn.Close()
58 defer remoteConn.Close()
59 io.Copy(localConn, remoteConn)
60 }()
61
62 go func() {
63 defer localConn.Close()
64 defer remoteConn.Close()
65 io.Copy(remoteConn, localConn)
66 }()
67 }
68 }()
69
70 return nil
71}
72
73func pullImage(cli *client.Client, imageName string) error {
74 log.Printf("pulling the image: image = %s\n", imageName)
75 reader, err := cli.ImagePull(context.Background(), imageName, image.PullOptions{})
76 if err != nil {
77 return err
78 }
79 defer reader.Close()
80
81 io.Copy(os.Stdout, reader)
82 return nil
83}
84
85func startContainer(cli *client.Client, imageName string) (string, error) {
86 log.Printf("creating a container: image = %s\n", imageName)
87 resp, err := cli.ContainerCreate(context.Background(), &container.Config{
88 Image: imageName,
89 }, nil, nil, nil, "")
90 if err != nil {
91 return "", err
92 }
93
94 log.Println("Starting the container...")
95 if err := cli.ContainerStart(context.Background(), resp.ID, container.StartOptions{}); err != nil {
96 return "", err
97 }
98
99 return resp.ID, nil
100}
101
102func main() {
103 serverAddr := os.Getenv("DOCKER_HOST")
104 localAddr := "localhost:2375"
105 user := os.Getenv("SSH_USER")
106 privateKey := os.Getenv("SSH_PRIVATE_KEY")
107
108 err := createSSHTunnel(user, privateKey, serverAddr, localAddr)
109 if err != nil {
110 log.Fatalf("Failed to create SSH tunnel: %s", err)
111 }
112
113 cli, err := client.NewClientWithOpts(client.WithHost("tcp://localhost:2375"))
114 if err != nil {
115 log.Fatalf("Failed to create Docker client: %s", err)
116 }
117
118 imageName := "hello-world:latest"
119
120 if err := pullImage(cli, imageName); err != nil {
121 log.Fatal(err)
122 }
123
124 containerID, err := startContainer(cli, imageName)
125 if err != nil {
126 log.Fatal(err)
127 }
128
129 log.Printf("container started: containerID = %s\n", containerID)
130}
The above code shows how to run a Docker container locally. By setting up an SSH tunnel, this same code can also manage Docker containers on a remote machine.
π Conclusions
In this article, I demonstrated two ways to manage Docker using the Go SDK:
Local Docker management via Go SDK
Remote Docker management via Go SDK over an SSH tunnel
The second method, using an SSH tunnel, requires no additional Docker configuration, provided you already have SSH access to the server. This method allows me to deploy my budget tracking application through the Docker API directly, without manually SSHing into the server.
You can find the examples from this article in the GitHub repository.