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.
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
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
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
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
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:
We have two actions triggered—one failed and one successful. Click on either action to see more 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.
Next, add a deployment rule to specify which branch can use this environment.
Now you can just add your environment 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)