<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Benedict Ell Nino</title>
    <description>The latest articles on DEV Community by Benedict Ell Nino (@ninoslat1).</description>
    <link>https://dev.to/ninoslat1</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2149545%2Fb54604f5-40e3-4cb0-80e3-35c94914716f.png</url>
      <title>DEV Community: Benedict Ell Nino</title>
      <link>https://dev.to/ninoslat1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ninoslat1"/>
    <language>en</language>
    <item>
      <title>Implement Simple CI/CD with GitHub Actions</title>
      <dc:creator>Benedict Ell Nino</dc:creator>
      <pubDate>Tue, 02 Dec 2025 07:01:59 +0000</pubDate>
      <link>https://dev.to/ninoslat1/implement-simple-cicd-with-github-actions-3bnb</link>
      <guid>https://dev.to/ninoslat1/implement-simple-cicd-with-github-actions-3bnb</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6re2at1kgpni21v9n45j.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6re2at1kgpni21v9n45j.jpg" alt="Container Ship by" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Container Ship by &lt;a href="https://unsplash.com/photos/blue-and-red-cargo-ship-on-sea-during-daytime-jOqJbvo1P9g?utm_source=63921&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Ian Taylor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This CI/CD configuration is provided as a reference based on my deployment environment.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  Local Development (Very Simple)
&lt;/h2&gt;

&lt;p&gt;Just add some .env configuration in &lt;code&gt;appsettings.json&lt;/code&gt; and run it like usual day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preparing Docker for Deployment
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Docker Setup
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;Relatively small image&lt;/li&gt;
&lt;li&gt;Self-contained executable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mcr.microsoft.com/dotnet/sdk:9.0-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;publish&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /src&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; your_project.csproj ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet restore &lt;span class="s2"&gt;"./your_project.csproj"&lt;/span&gt; &lt;span class="nt"&gt;--runtime&lt;/span&gt; linux-musl-x64

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet publish &lt;span class="s2"&gt;"your_project.csproj"&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;-c&lt;/span&gt; Release &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;-o&lt;/span&gt; /app/publish &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;--no-restore&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;--runtime&lt;/span&gt; linux-musl-x64 &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;--self-contained&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    /p:PublishSingleFile&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mcr.microsoft.com/dotnet/runtime-deps:9.0-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;final&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; icu-libs
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; LD_LIBRARY_PATH=/usr/lib&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk upgrade musl

&lt;span class="k"&gt;RUN &lt;/span&gt;adduser &lt;span class="nt"&gt;--disabled-password&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;--home&lt;/span&gt; /app &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;--gecos&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; dotnetuser &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; dotnetuser /app

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; dotnetuser&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=publish /app/publish .&lt;/span&gt;

&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["./your_project"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, create a docker compose for the local development and put all the &lt;code&gt;.env&lt;/code&gt;configuration on the environment section.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.8'&lt;/span&gt;

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

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;project_name_network&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then try to dockerize it with&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.Development.yml up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Deploying to Server via GitHub Actions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  GitHub Action Setup
&lt;/h3&gt;

&lt;p&gt;If the local deployment works, the next step is setting up environment variables as GitHub Actions Secrets for the server deployment.&lt;/p&gt;

&lt;p&gt;You can find it in:&lt;/p&gt;

&lt;p&gt;⚙ &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Security&lt;/strong&gt; → &lt;strong&gt;Secrets and Variables&lt;/strong&gt; → &lt;strong&gt;Actions&lt;/strong&gt; → &lt;strong&gt;New repository secret&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Copy-paste all environment variables from the local docker compose file into the Actions secrets.&lt;/p&gt;

&lt;p&gt;(&lt;strong&gt;Remember: keep the same namespace for easier setup.&lt;/strong&gt;)&lt;/p&gt;

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

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.8'&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dockerfile&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;project_name&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;hardware_port1:container_port1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;hardware_port2:container_port2&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ASPNETCORE_URLS=${ASPNETCORE_URLS}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MYSQL_CONNECTION=${MYSQL_CONNECTION}&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;project_name&lt;/span&gt;
    &lt;span class="na"&gt;extra_hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;host.docker.internal:host-gateway"&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;project_name_network&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, create a YAML workflow inside the &lt;code&gt;.github/workflows&lt;/code&gt; directory. It will automatically run when you push to the &lt;code&gt;master&lt;/code&gt; branch.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and Deploy&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;master&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-and-deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout code&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up Docker Buildx&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build Docker image&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;docker build -t your_project:latest .&lt;/span&gt;
        &lt;span class="s"&gt;docker save your_project:latest -o your_project.tar&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Create .env file from secrets&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;cat &amp;gt; .env &amp;lt;&amp;lt; EOF&lt;/span&gt;
        &lt;span class="s"&gt;ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT}&lt;/span&gt;
        &lt;span class="s"&gt;ASPNETCORE_URLS=${ASPNETCORE_URLS}&lt;/span&gt;
        &lt;span class="s"&gt;MYSQL_CONNECTION=${MYSQL_CONNECTION}&lt;/span&gt;
        &lt;span class="s"&gt;EOF&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Copy files to server&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/scp-action@master&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_HOST }}&lt;/span&gt;
        &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_USERNAME }}&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_PRIVATE_KEYS }}&lt;/span&gt;
        &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your_project.tar,docker-compose.yml,.env"&lt;/span&gt;
        &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tmp/deploy_your_project"&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy with Docker Compose&lt;/span&gt;
      &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@master&lt;/span&gt;
      &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_HOST }}&lt;/span&gt;
        &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_USERNAME }}&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_PRIVATE_KEYS }}&lt;/span&gt;
        &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;# Create app directory if not exists&lt;/span&gt;
          &lt;span class="s"&gt;mkdir -p ~/apps/your_project&lt;/span&gt;

          &lt;span class="s"&gt;# Move uploaded files into project directory&lt;/span&gt;
          &lt;span class="s"&gt;mv /tmp/deploy_your_project/docker-compose.yml ~/apps/your_project/&lt;/span&gt;
          &lt;span class="s"&gt;mv /tmp/deploy_your_project/.env ~/apps/your_project/&lt;/span&gt;

          &lt;span class="s"&gt;# Load docker image&lt;/span&gt;
          &lt;span class="s"&gt;docker load -i /tmp/deploy_your_project/your_project.tar&lt;/span&gt;

          &lt;span class="s"&gt;# Move tar file into project directory (optional)&lt;/span&gt;
          &lt;span class="s"&gt;mv /tmp/deploy_your_project/your_project.tar ~/apps/your_project/&lt;/span&gt;

          &lt;span class="s"&gt;# Deploy&lt;/span&gt;
          &lt;span class="s"&gt;cd ~/apps/your_project&lt;/span&gt;
          &lt;span class="s"&gt;docker compose down&lt;/span&gt;
          &lt;span class="s"&gt;docker compose up -d&lt;/span&gt;

          &lt;span class="s"&gt;# Clean temp folder&lt;/span&gt;
          &lt;span class="s"&gt;rm -rf /tmp/deploy_your_project&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This YML worfklow process is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build the Docker image&lt;/li&gt;
&lt;li&gt;Save it as a &lt;code&gt;.tar&lt;/code&gt; file&lt;/li&gt;
&lt;li&gt;Generate the &lt;code&gt;.env&lt;/code&gt; file from repository secrets&lt;/li&gt;
&lt;li&gt;Upload everything via SCP&lt;/li&gt;
&lt;li&gt;SSH into the server&lt;/li&gt;
&lt;li&gt;Load the Docker image&lt;/li&gt;
&lt;li&gt;Create a temporary directory for the deployment process&lt;/li&gt;
&lt;li&gt;Stop (if running) and start the docker container&lt;/li&gt;
&lt;li&gt;Remove the temporary directory&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Key Improvements
&lt;/h2&gt;

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

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




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>dotnet</category>
      <category>docker</category>
    </item>
  </channel>
</rss>
