DEV Community

Benedict Ell Nino
Benedict Ell Nino

Posted on

Implement Simple CI/CD with GitHub Actions

Container Ship by

Container Ship by Ian Taylor

As a software engineer working in a small company where IT is mostly seen as a cost center, I’ve been trying to keep our deployment process simple: small docker images, secure steps, and ideally… zero cost.

Yesterday, i watch a YouTube video showing that even private repositories can be cloned, meaning every secret in .env yml, or docker compose files could be exposed if we’re not careful.

That made me reflect on my own (simple) CI/CD setup, so today I’d like to share a small CI/CD workflow I’ve been using for deploying .NET apps to an on-premise server—nothing fancy. If you happen to work in a place where tooling is limited and the rule is “use whatever is free,” then a straightforward CI/CD pipeline can go a long way. It keeps deployments clean and your service running smoothly.

Disclaimer:

This CI/CD configuration is provided as a reference based on my deployment environment.

Server configurations may vary, so you might need to adjust directory paths, environment variables, or service names to make it work in your setup.


Local Development (Very Simple)

Just add some .env configuration in appsettings.json and run it like usual day.

Preparing Docker for Deployment

Docker Setup

First, create a Dockerfile for both the local deployment and server deployment. Here is the example of multistage build for .NET Web API (I set DOTNET_SYSTEM_GLOBALIZATION_INVARIANT to false, because there is time zone settings for the project). This Dockerfile give us:

  • Relatively small image
  • Self-contained executable

P.S: I am currently learning about the NativeAOT feature to trim the image into a smaller size, but I’m still researching whether it fits my use case. That’s why I use the basic multistage build for now.

FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS publish
WORKDIR /src

COPY your_project.csproj ./
RUN dotnet restore "./your_project.csproj" --runtime linux-musl-x64

COPY . .
RUN dotnet publish "your_project.csproj" \
    -c Release \
    -o /app/publish \
    --no-restore \
    --runtime linux-musl-x64 \
    --self-contained true \
    /p:PublishSingleFile=true

FROM mcr.microsoft.com/dotnet/runtime-deps:9.0-alpine AS final
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
RUN apk add --no-cache icu-libs
ENV LD_LIBRARY_PATH=/usr/lib
RUN apk upgrade musl

RUN adduser --disabled-password \
    --home /app \
    --gecos '' dotnetuser && chown -R dotnetuser /app

USER dotnetuser
WORKDIR /app
COPY --from=publish /app/publish .

ENTRYPOINT ["./your_project"]
Enter fullscreen mode Exit fullscreen mode

Second, create a docker compose for the local development and put all the .envconfiguration on the environment section.

version: '3.8'

services:
  project:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: project_name
    ports:
      - hardware_port1:container_port1
      - hardware_port2:container_port2
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - ASPNETCORE_URLS=http://+:container_port1;http://+:container_port2
      - MYSQL_CONNECTION=server=localhost;port=3306;userid=root;password=your_password;database=your_database
    restart: always
    networks:
      - project_name
    extra_hosts:
      - "host.docker.internal:host-gateway"

networks:
  project_name:
    name: project_name_network
    driver: bridge
Enter fullscreen mode Exit fullscreen mode

Then try to dockerize it with

docker compose -f docker-compose.Development.yml up -d --build
Enter fullscreen mode Exit fullscreen mode

If it works, add the development docker-compose file and appsettings.json to .gitignore to prevent them from being pushed to the repository, and also add appsettings.json to .dockerignore (since all envs are already in the docker compose for local deployment).

Deploying to Server via GitHub Actions

GitHub Action Setup

If the local deployment works, the next step is setting up environment variables as GitHub Actions Secrets for the server deployment.

You can find it in:

SettingsSecuritySecrets and VariablesActionsNew repository secret

Copy-paste all environment variables from the local docker compose file into the Actions secrets.

(Remember: keep the same namespace for easier setup.)

Also, don’t forget to prepare your SSH private key, host (the VPS IP address), and username for SSH access.

After setting up the repository secrets, create a separate docker-compose file for server deployment. Copy the local one and replace all environment values using the secret namespaces.

version: '3.8'

services:
  project:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: project_name
    ports:
      - hardware_port1:container_port1
      - hardware_port2:container_port2
    environment:
      - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT}
      - ASPNETCORE_URLS=${ASPNETCORE_URLS}
      - MYSQL_CONNECTION=${MYSQL_CONNECTION}
    networks:
      - project_name
    extra_hosts:
      - "host.docker.internal:host-gateway"

networks:
  project_name:
    name: project_name_network
    driver: bridge
Enter fullscreen mode Exit fullscreen mode

Next, create a YAML workflow inside the .github/workflows directory. It will automatically run when you push to the master branch.

The reason why I am not using Docker Hub in this pipeline is because it requires additional credentials that I would need to store in GitHub Secrets. Since my company only has one main server, a registry isn’t necessary for this setup.

name: Build and Deploy

on:
  push:
    branches: [ master ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

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

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

    - name: Build Docker image
      run: |
        docker build -t your_project:latest .
        docker save your_project:latest -o your_project.tar

    - name: Create .env file from secrets
      run: |
        cat > .env << EOF
        ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT}
        ASPNETCORE_URLS=${ASPNETCORE_URLS}
        MYSQL_CONNECTION=${MYSQL_CONNECTION}
        EOF

    - name: Copy files to server
      uses: appleboy/scp-action@master
      with:
        host: ${{ secrets.SSH_HOST }}
        username: ${{ secrets.SSH_USERNAME }}
        key: ${{ secrets.SSH_PRIVATE_KEYS }}
        source: "your_project.tar,docker-compose.yml,.env"
        target: "/tmp/deploy_your_project"

    - name: Deploy with Docker Compose
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.SSH_HOST }}
        username: ${{ secrets.SSH_USERNAME }}
        key: ${{ secrets.SSH_PRIVATE_KEYS }}
        script: |
          # Create app directory if not exists
          mkdir -p ~/apps/your_project

          # Move uploaded files into project directory
          mv /tmp/deploy_your_project/docker-compose.yml ~/apps/your_project/
          mv /tmp/deploy_your_project/.env ~/apps/your_project/

          # Load docker image
          docker load -i /tmp/deploy_your_project/your_project.tar

          # Move tar file into project directory (optional)
          mv /tmp/deploy_your_project/your_project.tar ~/apps/your_project/

          # Deploy
          cd ~/apps/your_project
          docker compose down
          docker compose up -d

          # Clean temp folder
          rm -rf /tmp/deploy_your_project

Enter fullscreen mode Exit fullscreen mode

This YML worfklow process is:

  1. Build the Docker image
  2. Save it as a .tar file
  3. Generate the .env file from repository secrets
  4. Upload everything via SCP
  5. SSH into the server
  6. Load the Docker image
  7. Create a temporary directory for the deployment process
  8. Stop (if running) and start the docker container
  9. Remove the temporary directory

Key Improvements

There are also a couple of small tweaks that I found while working on this simple CI/CD setup.

  • Optimize the Docker image size by using a minimal Alpine base image and optionally leveraging .NET NativeAOT for trimming the final binary.
  • Improve security and build reproducibility by pinning base images using SHA256 digests (e.g., FROM alpine:3.19@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412 AS stage_name )
  • Add a healthcheck to the docker-compose file to ensure the service is actually running before marking it healthy.

Conclusion

I’ve been wearing “multiple hats” here, so I built a CI/CD pipeline that stays simple: no registry, no cloud services, and definitely no extra cost. Just GitHub Actions, Docker, and a good old SSH deployment to an on-prem server.

Thanks for reading — I hope this setup helps anyone who is still doing manual SSH, git clone, and build steps on the server. If this workflow inspires you, then the article has done its job.

Top comments (0)