📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📰 From Manual Deployments to GitHub Actions: I Don't Touch My Server Anymore

From Manual Deployments to GitHub Actions: I Don't Touch My Server Anymore

From Manual Deployments to GitHub Actions: I Don't Touch My Server Anymore

For a long time, deploying my app was a small ritual. Not a fun one.

I'd finish a feature, test it on my machine, feel good about it, and then remember that I still had to deploy it. That part could easily take longer than the feature itself.

This post is about how I went from doing every deployment by hand to letting GitHub Actions do it for me, and why I'll never go back.

How I used to deploy

This was my process, more or less every time:

  1. Make sure I still had access to the server. Find the password or the key, check the IP, hope nothing had changed.
  2. Publish the app on my local machine with dotnet publish, in Release mode. (Yes, I forgot the Release part more than once.)
  3. SSH into the server.
  4. Stop the app.
  5. Copy the current version into a backup folder, so I could roll back if something broke.
  6. Transfer the new files from my laptop to the server.
  7. Start the app again and check the logs.
  8. Open the site and click around to make sure it actually worked.

And that was for one environment. I had to do it on testing first, check everything there, and then do the whole thing again for production.

On a good day it took maybe 20 to 30 minutes. On a bad day, much longer.

The bad days

The bad days were the worst part, and they were never dramatic. It was always something small.

Sometimes I missed a file. I'd copy the publish folder, but an old file from a previous version was still sitting on the server, and the app picked up the wrong thing.

Sometimes an appsettings file got overwritten with the values from my laptop. Suddenly the app on the server was trying to connect to localhost for its database.

Sometimes a package, a runtime or some bundle wasn't there on the server, and the app just refused to start. The error message told me almost nothing. The site showed a 502, the logs showed a stack trace, and I had to work out what was different between my machine and the server.

Then I'd be in the terminal at night, comparing folders, restoring the backup, trying again, and checking it on testing before I even dared to touch production.

The worst part wasn't the time. It was the feeling. I never really trusted a deployment until I'd checked it myself. And slowly, without noticing, I started to avoid deploying small changes. A two-line fix didn't feel worth thirty minutes of SSH and nerves. So changes piled up, and then every deployment was bigger, and bigger deployments were exactly the ones that went wrong.

Then I tried GitHub Actions

I'd heard about GitHub Actions for a while. In my head it was something for big teams with proper DevOps engineers, not for someone like me deploying a few personal apps.

When I finally sat down and wrote my first workflow, I was surprised how simple the idea is.

A workflow is just a YAML file in your repository, in a .github/workflows folder. You say when it should run (for example, when you push to main), and what it should do (a list of steps). When the trigger happens, GitHub starts a fresh virtual machine, called a runner, checks out your code, and runs your steps one by one. You can watch the logs live in the Actions tab.

The first version I wrote only built and tested the app. No deployment at all. I pushed it, opened the Actions tab, and watched it go yellow, then green. It was a tiny thing, but it felt good. For the first time, something other than me was checking that my code built and the tests passed.

Adding the deployment

Once build and test were working, I added publishing and deployment. My workflow today looks something like this (simplified, with example names):

name: Build and deploy

on:
  push:
    branches: [ main, develop ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --configuration Release --no-restore

      - name: Test
        run: dotnet test --configuration Release --no-build

      - name: Publish
        run: dotnet publish src/MyApp -c Release -o ./publish --no-build

      - name: Upload build
        uses: actions/upload-artifact@v4
        with:
          name: app
          path: ./publish

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: ${{ github.ref_name == 'main' && 'production' || 'uat' }}
    steps:
      - name: Download build
        uses: actions/download-artifact@v4
        with:
          name: app
          path: ./publish

      - name: Deploy over SSH
        env:
          SSH_KEY: ${{ secrets.SSH_KEY }}
          SSH_TARGET: ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}
          RELEASE: /var/www/myapp/releases/${{ github.sha }}
        run: |
          mkdir -p ~/.ssh
          echo "$SSH_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          rsync -az -e "ssh -o StrictHostKeyChecking=accept-new" ./publish/ "$SSH_TARGET:$RELEASE/"
          ssh "$SSH_TARGET" "ln -sfn $RELEASE /var/www/myapp/current && sudo systemctl restart myapp"

It looks like a lot at first, but if you read it slowly, it's just my old manual checklist written down:

  • Restore, build, test, publish. Exactly what I used to do on my laptop, except now it happens on a clean machine every time.
  • Upload and download the build. The build job saves the published files, and the deploy job picks up exactly those files. What was tested is what gets deployed.
  • Copy to the server and restart. The same SSH and file copy I used to do by hand, now done by a script.

The first time I pushed and watched the whole thing run, it honestly felt like magic. A green tick, and my app was updated on the server.

But then I looked at the file again and laughed a bit, because there's no magic in it at all. It's the same commands I was already typing by hand. The only difference is that now a machine runs them for me. The same way, in the same order, every single time.

Two environments, one workflow

One thing I really like is how it handles UAT and production.

When I push to the develop branch, the workflow deploys to UAT. When I merge into main, it deploys to production. Same workflow file, same steps. The only thing that changes is the environment.

In GitHub, you can create environments (I have uat and production) and give each one its own secrets. So SSH_HOST in the UAT environment points to my testing server, and SSH_HOST in the production environment points to my production server. The workflow doesn't need to know the difference. It just asks for SSH_HOST, and GitHub gives it the right one.

For production, I also turned on a required reviewer. When a deployment to production starts, it pauses and waits for me to click Approve. It sounds silly when you're the only reviewer, but it's a nice last moment to think "am I sure?" before anything goes live.

After years of UAT and production living way too close to each other, this separation felt really good. It's not just two folders anymore. It's two environments, with different secrets, and a clear path from one to the other.

Rollback without the backup folder

Remember step 5 of my old process, copying the current version into a backup folder? I always did it, because I was scared of a bad deployment with no way back.

Now every deployment goes into its own folder, named after the commit. A link called current points to the active one. So on the server it looks something like this:

/var/www/myapp/
  releases/
    3f2a91c.../
    8bd04e7.../
    c19e5aa.../
  current -> releases/c19e5aa...

If a release is bad, rolling back is just pointing current at the previous folder and restarting the app. Or even simpler, I re-run the workflow for the last good commit from the Actions tab, and it deploys that version again.

I still keep backups, of course, especially of the database. But I don't do the nervous "copy everything just in case" step by hand anymore.

What about servers that aren't on the internet?

This is something I had to think about with my homelab. The runner is a machine in GitHub's cloud. To deploy, it has to reach my server. But my servers aren't open to the internet, and I don't want them to be.

There are a few ways to handle this. One is Tailscale's GitHub Action, which lets the runner join your private Tailscale network just for the duration of the job, and then disappear. Another is a self-hosted runner, where the GitHub Actions agent runs on a machine inside your own network and picks up jobs from GitHub.

Both of them mean the server never needs an open SSH port to the whole internet. The deployment comes in through a door that only the workflow can use.

What changed for me

The time is the obvious one. What used to take me half an hour now happens on its own in a couple of minutes, and I don't even have to watch it.

The tests run before anything is deployed. If a test fails, the deploy job never starts. Before, testing was something I did manually after deploying, if I wasn't too tired.

I don't touch the server anymore. I don't SSH in to copy files. I don't look for passwords. The server details live in GitHub secrets, and the workflow uses them. I only log in to the server now when I actually want to look at something, not because I have to.

And the biggest one for me: I stopped worrying about missing files. The workflow publishes everything from a clean build, every time, on a fresh machine. No forgotten DLL, no missing bundle, no old file left behind from three versions ago, no appsettings from my laptop. If it builds and the tests pass, what goes to the server is exactly what's in the repo.

Because deploying is so easy now, I deploy small changes without thinking twice. A two-line fix goes out the same day. That alone made me a better developer, I think. Smaller changes are easier to test, easier to review and much easier to roll back.

There's also a history now. Every deployment is in the Actions tab, with the commit, the time, the logs and who approved it. If something breaks, I can see exactly what changed and when. Before, the only history was my memory, and my memory at 11pm is not great.

It's not perfect

I won't pretend it was all smooth. Writing the workflow the first time took me a few attempts. A wrong path to the project file. A secret with a typo in its name. An SSH key pasted with a missing line. A step that worked on my laptop but not on the runner, because the runner is Linux and I'd been testing on Windows. I pushed a lot of commits called "fix workflow" that day.

Debugging a workflow is also a bit slower than debugging code. You change something, push, wait for the runner to start, and read the logs. Small mistakes cost a few minutes each time. My tip: get the build and test steps working first, and only then add the deploy steps one by one.

And you still need to understand what's happening underneath. If you've never deployed by hand, a failing workflow can be really confusing, because you don't know what each command is supposed to do. Funny enough, all those nights of deploying manually are exactly why I could write the YAML file in the first place. I already knew every step. I just had to write them down.

If you're still deploying by hand

Just try it once, with a small project. This is the order I'd suggest:

  1. Start with only build and test. No deployment. Watch it go green.
  2. Add the publish step, and upload the result as an artifact. Download it and check it looks right.
  3. Add a deploy step to a test server, not production.
  4. Move your server details into GitHub secrets and environments.
  5. Only then point it at production, with an approval step if you like.

You don't need to be a DevOps engineer. If you can write down the steps you do by hand, you can write a workflow. That's really all it is.

I wish I'd done it a lot earlier.

Are you still deploying manually, or did you already make the switch? And if you use something other than GitHub Actions, like Azure DevOps or GitLab CI, I'd like to hear how it went for you. Let me know in the comments.

X / Twitter LinkedIn

Comments (0)

No comments yet. Be the first!

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me