Replacing local deploy commands with SSH-based remote deploy

This was the point where the project started feeling a bit more like a real PaaS…

Before replacing the local deploy flow, I first set up a proper target server for SSH-based deployment. The rough steps were: create a dedicated uploy user on the Ubuntu VM, generate an SSH key on my Mac, register the public key on the server, install Docker there, add uploy to the docker group, and make sure remote commands like docker ps and docker pull nginx:latest could run successfully.

Once that setup was working, I no longer wanted deployment to happen with local exec.Command(...) on my own machine. In this commit, I replaced that local flow with an SSH-based remote deploy.

The main change was that RunNginx() became RunDeploy(...), and instead of assuming everything runs locally, the backend now accepts deploy config from the request body: host, port, user, private key, image, container name, and port. From there, the backend opens an SSH connection to the target server and runs the Docker commands remotely.

What I like about this step is that the shape now feels much closer to the real product direction. The backend is no longer just automating Docker on my laptop… it is starting to behave like a control plane that talks to another machine.

Control plane to target server

I also kept the deployment logs flowing line by line through the same logging path, so from the frontend I can still watch what happens while the remote docker pull and docker run are executing.

But right after that, I ran into a very annoying bug. While testing the SSH stream, I got this panic: panic: send on closed channel

That turned out to come from StreamCommand(...). The outer goroutine could finish and close the stdout / stderr channels while the inner scanner goroutines were still trying to send lines into them. So the deployment itself was moving in the right direction, but the stream lifecycle was still fragile.

That is what I fixed in this follow-up commit. I added a WaitGroup so the pipe-reading goroutines get a chance to finish draining stdout and stderr before the channels are closed. I also turned the hardcoded deploy values in the frontend into a proper form, so I can now fill in host, port, user, private key, image, container name, and container port directly from the UI.

This was a nice reminder that moving from local process execution to remote execution is not just about swapping exec.Command with SSH. The streaming part becomes more subtle too… once there are multiple goroutines reading multiple pipes, channel closing order suddenly matters a lot.

I also recorded a demo for this one. In the video, I fill out the deploy form in the frontend, click Deploy, and the container gets deployed to the target server over SSH instead of running locally on my machine. That felt like a pretty satisfying milestone honestly.

Here’s the demo video (the Host field is blacked out to hide the server IP)…

© 2026 Wahyu Syahputra. All rights reserved.