All posts
§03 · Writing26.05.204 min read

The Moment I Stopped SSH-ing Into Production

The worst way to deploy software is to SSH into a production server and run commands by hand.

I know because I did it. For longer than I should have.

The mental load is absurd. You're managing a checklist in your head: pull the latest code, stop the container, rebuild the image, restart, check the logs, confirm it's up. One typo. One forgotten step. One network blip at the wrong moment. The whole thing can go sideways, and you're sitting there at 23:00 on a Friday trying to remember what state you left the server in.

The moment I wired up a proper CI/CD pipeline was the moment deployments became boring. Boring is the goal.

What the Pipeline Does

The pipeline runs on GitHub Actions and triggers on every push to main. It does four things across two jobs:

Job 1 — Build and Push:

  1. Compiles and tests the Java API with Maven.
  2. Builds a Docker image for the Spring Boot API targeting linux/arm64 (the EC2 is a t4g.micro, ARM64 — QEMU on the GitHub runner handles the cross-compilation).
  3. Pushes the image to Amazon ECR with a short SHA tag for deterministic identification.

Job 2 — Deploy: 4. Generates a short-lived ECR auth token on the runner. 5. SSHs into the EC2 instance, writes the .env and docker-compose.yml files, passes the ECR token as an environment variable, pulls the new image, and restarts the stack.

AWS credentials (access key ID and secret) live as GitHub Actions secrets. The ECR auth token is generated fresh on the runner for each deploy and passed to the EC2 over SSH — the instance never stores long-lived AWS credentials. The EC2's IAM instance profile is separate: it gives the running Spring Boot container access to DynamoDB and S3 at runtime.

The Key Insight: Predictability, Not Speed

The pipeline doesn't make deployments faster. A manual docker-compose pull && docker-compose up -d takes about the same wall-clock time as the automated version.

What it makes impossible is human error.

When I deploy manually, every deployment is slightly different. Different shell history, different environment variables loaded, different mental state. When the pipeline deploys, every deployment is identical. The same sequence of commands, the same flags, the same order of operations, every time. The process is immutable in the same way a Docker image is immutable — you get the same result no matter who runs it or when.

That predictability is what lets you sleep. You can look at any deployment and know exactly what happened, because it's the same thing that always happens.

The Nuxt Frontend Is Different

The Nuxt frontend doesn't go through ECR and Docker. It's deployed to Vercel via their GitHub integration — push to main, Vercel builds and deploys automatically. This is one case where the managed platform is genuinely the right choice: Vercel's build and edge CDN infrastructure for a Nuxt app is hard to beat on a zero-budget project.

The Spring Boot API and the Nuxt frontend use different deployment strategies because they have different constraints. That's the right call. A pipeline should fit the artifact, not impose uniformity for its own sake.

What Changed

Before the pipeline: I avoided deploying small changes because the friction wasn't worth it. I'd batch up a week of changes and do one big deploy, which made debugging harder when something broke.

After the pipeline: I push small changes freely. If something breaks, I know exactly which commit introduced it. The feedback loop is tight enough that errors surface and get fixed quickly, instead of accumulating into a mess.

The pipeline doesn't just automate deployment. It changes how you write code.


The most underrated benefit of CI/CD isn't the automation. It's the discipline it forces on the size and scope of your commits.