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"]
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
Then try to dockerize it with
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 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:
⚙ Settings → Security → Secrets and Variables → Actions → New 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
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
This YML worfklow process is:
- Build the Docker image
- Save it as a
.tarfile - Generate the
.envfile from repository secrets - Upload everything via SCP
- SSH into the server
- Load the Docker image
- Create a temporary directory for the deployment process
- Stop (if running) and start the docker container
- 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)