Implement Simple CI/CD with GitHub Actions
Source: Dev.to

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
- In the repository, navigate to Settings → Security → Secrets and Variables → Actions → New repository secret.
- Add each environment variable from the local Docker Compose file as a secret, preserving the same names (e.g.,
ASPNETCORE_ENVIRONMENT). - Add SSH credentials as secrets:
SSH_HOST,SSH_USERNAME, andSSH_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