DEV Community

Cover image for Deployment Workflow with GitHub Action 🚢

Deployment Workflow with GitHub Action 🚢

Introduction

Deploying code can be a headache—pushing updates, running tests, and making sure everything works without breaking production. That's where deployment automation comes in. Instead of doing everything manually, you set up a system that handles the repetitive stuff for you.

GitHub Actions makes this process way easier. Since it's built right into GitHub, you don't need to juggle multiple tools or services. You can set up automated workflows that test your code, build your project, and deploy it—all triggered by things like pushing commits or merging pull requests. It's basically your personal deployment assistant that works whenever you need it.

What is GitHub Actions?

GitHub Actions is built around a few simple ideas that work together:

  • Workflows are like instruction manuals that tell GitHub what to do. They're just text files that sit in your project.
  • Jobs are the major tasks in your workflow—like "test the code" or "deploy to production." They can run one after another or all at once.
  • Steps are the individual actions within each job. Think of them as the actual commands you'd type if you were doing this manually.
  • Runners are the computers that actually do the work. GitHub provides them for free, or you can use your own if you need something specific.

Why people like using GitHub Actions:

  • It's already part of GitHub, so you don't need to sign up for another service or sync anything.
  • You can automate pretty much anything—testing, building, deploying, even sending notifications.
  • It's flexible enough to work with almost any language, framework, or deployment target you can think of.

Setting Up Your First Deployment

Okay, enough of the lecture, let’s start the implementation right away. For this tutorial, we’ll do a simple deployment on a Nest.js application. To help you better understand the flow, let’s take a look at this picture flow.

Our workflow example

When you push to the staging branch, it triggers the workflow. There are two jobs: build-and-publish and deploy. The first job builds the project and uploads the package to the cloud container registry. The second job retrieves environment variables from GitHub secrets, securely sends them to the VPS, pulls the update, and runs the latest package version. That’s it. Pretty simple, right?

Alright, now let’s make our hands dirty. First, you need to create a folder like this:

- .github
    - workflows
        - staging.yaml #feel free to rename this
Enter fullscreen mode Exit fullscreen mode

All GitHub Actions files go inside the .github/workflows folder.

Now, let's define the first rule to determine when the workflow should run.

name: Staging - Build, Push and Deploy

on:
  push:
    branches:
      - staging

env:
  REGISTRY: ghcr.io
  PROJECT_KEY: github-action-example
Enter fullscreen mode Exit fullscreen mode

In this step, we want the workflow to be triggered when someone pushes code to the staging branch. We'll also define environment variables that will be used in the next steps.

Then, we’ll define our first job, which is Build and Push.

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_tag: ${{ steps.get_sha.outputs.short_sha }}
      repo_owner: ${{ steps.lowercase.outputs.owner }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Get commit SHA
        id: get_sha
        run: |
          echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
          echo "full_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Lowercase repository owner
        id: lowercase
        run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT

      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.owner }}/${{ env.PROJECT_KEY}}
          tags: |
            type=ref,event=branch
            type=sha,prefix=,format=short
            type=raw,value=staging-latest

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            COMMIT_SHA=${{ steps.get_sha.outputs.short_sha }}
        env:
          DOCKER_BUILDKIT: 1
Enter fullscreen mode Exit fullscreen mode

We set permissions to read content and write packages. This lets us read the repository content (source code) and publish to GitHub Container Registry. We also create outputs for image_tag and repo_owner.
First, we check out the current branch and get the short and full SHA commit. Then we set up Docker Buildx (basically a Docker builder) and log in to GitHub Container Registry. Next, we transform the repository owner to lowercase (ghcr forces package names to be lowercase :D). Finally, we grab the metadata, build the image, and push the package to the registry.

Great, now let’s define our second job, which is deploy.

deploy:
    runs-on: ubuntu-latest
    needs: build-and-push
    environment: staging
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Create .env file from .env.example
        env:
          SECRETS_JSON: ${{ toJSON(secrets) }}
        run: |
          # Parse secrets JSON
          echo "$SECRETS_JSON" > /tmp/secrets.json

          # Read .env.example and create .env with actual values
          while IFS= read -r line || [ -n "$line" ]; do
            # Skip comments and empty lines
            if [[ "$line" =~ ^[[:space:]]*# ]] || [[ -z "${line// }" ]]; then
              continue
            fi

            # Extract key name (before =)
            if [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)= ]]; then
              key="${BASH_REMATCH[1]}"

              # Get value from secrets
              value=$(jq -r --arg key "$key" '.[$key] // empty' /tmp/secrets.json)

              echo "${key}=${value}" >> .env
            fi
          done < .env.example

          # Clean up temp files
          rm -f /tmp/secrets.json

          echo "Generated .env file:"
          cat .env

      - name: Copy env files to server
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.VPS_STAGING_HOST }}
          username: ${{ secrets.VPS_STAGING_USER }}
          key: ${{ secrets.VPS_STAGING_KEY }}
          source: '.env'
          target: ${{ env.PROJECT_KEY }}
          overwrite: true
          debug: false

      - name: SSH Deploy
        uses: appleboy/ssh-action@v0.1.6
        with:
          host: ${{ secrets.VPS_STAGING_HOST }}
          username: ${{ secrets.VPS_STAGING_USER }}
          key: ${{ secrets.VPS_STAGING_KEY }}
          debug: false
          script: |
            # Navigate to staging deployment directory
            cd ${{ env.PROJECT_KEY }}

            # Restore git repository state
            git restore .

            # Pull latest changes
            git pull origin staging

            # Login to GitHub Container Registry
            echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

            # Set environment variables for image tags
            export IMAGE_TAG=${{ needs.build-and-push.outputs.image_tag }}
            export REPO_OWNER=${{ needs.build-and-push.outputs.repo_owner }}
            export BUILT_IMAGE=ghcr.io/${REPO_OWNER}/${{ env.PROJECT_KEY }}:${IMAGE_TAG}

            # Pull backend image
            docker pull ${BUILT_IMAGE}

            # Replace placeholders in docker-compose-prod.yml
            sed -i "s|--built-image--|${BUILT_IMAGE}|g" docker-compose-prod.yml

            # Stop existing containers
            docker compose -f docker-compose-prod.yml down

            # Sleep for a few seconds to ensure proper shutdown
            sleep 5

            # Start services
            docker compose -f docker-compose-prod.yml up -d

Enter fullscreen mode Exit fullscreen mode

This job depends on the build-and-push job, so it waits until that finishes. We specify the GitHub environment as staging (more on this below).
First, we check out the current branch. Then we create an .env file based on .env.example and inject values from GitHub environment variables. Next, we securely send the .env file via SSH to our project directory. Finally, we pull the latest updates, retrieve the newest package, and run the application.

If you are interested in seeing the full implementation code, feel free to check this repo.

Nice work! Now let's check out GitHub Actions:

GitHub Action Tab

We have two actions triggered—one failed and one successful. Click on either action to see more details.

GitHub Action Details

Here you can inspect your workflow and see what's happening. Just expand each step to see more details about the process.

Conclusion

So yeah, GitHub Actions is pretty solid for automating deployments. Once you've got your workflows set up, you don't have to manually push updates or worry about whether you forgot a step. It just handles the build, test, and deploy cycle for you. Whether you're working solo on a side project or with a team on something bigger, automating this stuff frees you up to actually build features instead of babysitting deployments.

Bonus: Setting up GitHub Environment

In our workflow, we're using GitHub Environment to store sensitive secrets and environment variables. Whenever you need to update or add new secrets, you can do it directly in GitHub. Just go to your repository settings and click on Environments. From there, you can create a new environment with whatever name you want.

Creating Environment

Next, add a deployment rule to specify which branch can use this environment.

Setting Rules

Now you can just add your environment secrets.

Add or Edit Secrets

Thank you for reading this article, hope it's helpful 📖. Don't forget to follow the account for the latest updates 🌟. See you in the next article 🙌.

Top comments (0)