Implement Simple CI/CD with GitHub Actions

Published: (December 2, 2025 at 02:01 AM EST)
4 min read
Source: Dev.to

Source: Dev.to

Container Ship by Ian Taylor

Container Ship by Ian Taylor

As a software engineer in a small company where IT is often seen as a cost center, I try to keep our deployment process simple: small Docker images, secure steps, and ideally zero cost.

A recent YouTube video showed that even private repositories can be cloned, meaning secrets in .env, yml, or Docker Compose files could be exposed if we’re not careful. This prompted me to review my own (simple) CI/CD setup. Below is a lightweight workflow I use to deploy .NET apps to an on‑premise server. It’s meant as a reference; you may need to adjust paths, environment variables, or service names for your environment.

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)

Add the necessary configuration to appsettings.json (or another config file) and run the application locally as usual.

Preparing Docker for Deployment

Docker Setup

Create a multistage Dockerfile for both local and server deployments. The example below builds a self‑contained .NET Web API image on Alpine, sets DOTNET_SYSTEM_GLOBALIZATION_INVARIANT to false (required for time‑zone settings), and creates a non‑root user.

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"]

Create a Docker Compose file for local development, placing all environment variables in 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

Run the composition:

docker compose -f docker-compose.Development.yml up -d --build

If it works, add the development Docker Compose file and appsettings.json to .gitignore to prevent them from being pushed, and also add appsettings.json to .dockerignore (since all envs are already in the compose file).

Deploying to Server via GitHub Actions

GitHub Action Setup

  1. In the repository, navigate to Settings → Security → Secrets and Variables → Actions → New repository secret.
  2. Add each environment variable from the local Docker Compose file as a secret, preserving the same names (e.g., ASPNETCORE_ENVIRONMENT).
  3. Add SSH credentials as secrets: SSH_HOST, SSH_USERNAME, and SSH_PRIVATE_KEYS.

Create a server‑specific Docker Compose file that references the secrets:

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

Add a workflow file (e.g., .github/workflows/deploy.yml) that builds the image, transfers it and the compose file to the server, and runs the deployment.

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=${{ secrets.ASPNETCORE_ENVIRONMENT }}
          ASPNETCORE_URLS=${{ secrets.ASPNETCORE_URLS }}
          MYSQL_CONNECTION=${{ secrets.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 it doesn't exist
            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

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

            # Navigate to the app directory and start the services
            cd ~/apps/your_project
            docker compose up -d --remove-orphans
Back to Blog

Related posts

Read more »

Jenkins na AWS + Docker

!Cover image for Jenkins na AWS + Dockerhttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-upload...