Accra, Ghana ·
Back to writing
30 August 2026·11 min read

From Git Push to Production: How I Deploy My Applications

How I built a reusable deployment stack with GitHub Actions, GHCR, a VPS, Docker Compose, Infisical, and Cloudflare.

devopsdockergithub actionsvpsinfrastructuredeployment

For a long time, deploying an application was almost an afterthought.

Push the code to GitHub, connect the repository to a hosting platform, add a few environment variables, and a couple of minutes later you had a production URL.

And for a while, a lot of this was free.

The free tiers were more than enough for the kinds of applications I was building. Even when there were limitations, they were easy to live with. I didn't have to think about servers, Docker, reverse proxies, certificates, networking, or most of the infrastructure underneath my applications.

It was a really good deal.

But over time, that started to change.

Free tiers became more restrictive. Some disappeared entirely. Features that used to be included moved behind paid plans. And once an application needed more than a simple frontend — an API, a database, Redis, a background worker, maybe another service — the monthly cost could start adding up surprisingly quickly.

Individually, none of those costs seemed unreasonable.

$5 here. $10 there. Another $7 for something else.

The problem was that I wasn't running one application.

As I started building more things, I was effectively paying for the same underlying resources over and over again.

That made me question the way I was deploying applications.

I didn't necessarily need five separate platforms.

I needed compute.

I needed somewhere to run containers, and I needed a reliable way to get my code from GitHub onto that compute.

So instead of looking for another hosting platform, I started building a deployment setup of my own.

Today, most of my applications follow roughly the same path to production:

GitHub → GitHub Actions → GHCR → VPS → Docker Compose

With Infisical handling secrets and Cloudflare sitting at the edge.

None of these tools are particularly unusual on their own. What I find interesting is how putting them together has given me a small deployment platform that I understand, control, and can reuse across different applications.

This is how it works.


The VPS became my compute layer

The first change was pretty simple: rent a VPS.

Instead of paying separately for an API here, a worker there, Redis somewhere else, and another service somewhere else again, I could rent a server and decide how I wanted to use its resources.

That changed the economics quite a bit.

If one application barely uses any memory and another needs a background worker, I don't need to think about them as separate hosting plans. They're workloads running on the same infrastructure.

Obviously, a VPS isn't magically free.

I'm still paying for compute.

The difference is that I'm paying for a pool of resources and deciding how to allocate them instead of paying for every application component individually.

But getting a server was the easy part.

Now I had to decide how applications actually got onto it.


Docker gave me a common deployment unit

I didn't want every application on the server to have its own custom setup.

One project might use Node.js. Another might need a different version. One could have a worker. Another might need Redis.

I also didn't want deployment to mean SSHing into a server and manually installing whatever the latest application happened to need.

Docker solved a lot of that for me.

Each application describes how it should be built in a Dockerfile, and what reaches production is a container image.

That gives me a consistent deployment unit regardless of what is inside the application.

From the server's perspective, it doesn't particularly matter whether a container contains an Express API, a Next.js application or a background worker.

It's a container.

And that became an important part of the way I wanted the whole system to work.


I didn't want my VPS to be a build server

The obvious next step would have been to SSH into the server whenever I wanted to deploy and do something like:

git pull
npm install
npm run build
restart the application

I've deployed applications like this before, and it works.

But there are a few things about it that I don't particularly like.

Production now needs my source code and all the tooling required to build it. Deployments depend more heavily on the current state of the server. And a build can consume quite a bit of CPU and memory on the same machine that is already running my applications.

I wanted the VPS to have a much smaller responsibility.

Run containers.

Not clone repositories.

Not install npm packages.

Not compile TypeScript.

Not build frontend bundles.

Just run the result.

So I moved the build process somewhere else.


GitHub Actions became my build system

My code already lived on GitHub, so GitHub Actions was a natural place for the CI/CD pipeline.

A deployment starts with a push.

Depending on the project, that might be a push to a specific deployment branch or a manually triggered workflow.

GitHub Actions checks out the repository and builds the Docker image.

So I now have a useful separation:

GitHub Actions = build
VPS            = run

That separation sounds simple, but I really like it.

The production server doesn't need to understand how to build my application. It only needs to understand how to run the resulting container.

Once the image has been built, I need somewhere to put it.

That's where GHCR comes in.


GHCR connects CI to production

After GitHub Actions builds an image, it pushes it to GitHub Container Registry.

My pipeline now looks something like this:

Code
  ↓
GitHub
  ↓
GitHub Actions
  ↓
Docker Image
  ↓
GHCR

The VPS has credentials that allow it to pull the images it needs from the registry.

That means production isn't pulling my repository and trying to reproduce the build.

It is pulling the result of the build.

I like this model because there is a clear artifact moving through the system.

My application goes into CI.

CI produces an image.

The registry stores that image.

Production runs that image.

There is much less ambiguity about what exactly is being deployed.


Docker Compose describes what actually runs

A container image solves one part of the problem.

Real applications usually need more than one thing running.

An API might have a worker. The worker might need Redis. The application might need PostgreSQL. There could be another service alongside all of those.

This is where Docker Compose fits into my setup.

A simplified application might look something like:

services:
  api:
    image: ghcr.io/example/my-app-api
    restart: unless-stopped

  worker:
    image: ghcr.io/example/my-app-worker
    restart: unless-stopped

  redis:
    image: redis:alpine
    restart: unless-stopped

  postgres:
    image: postgres:17
    restart: unless-stopped

The real Compose files obviously contain more than this — volumes, networks, health checks, environment configuration and other application-specific details — but the important thing is what Compose gives me conceptually.

It describes the application as a collection of services.

If I need a worker, I add a worker.

If I need Redis, I add Redis.

I don't have to go back to a hosting dashboard and figure out which product or pricing tier corresponds to that new part of my architecture.

It's another container.

That freedom is probably one of my favourite parts of this setup.


Secrets needed their own home

Once I had deployments automated, environment variables became the next problem.

.env files are fine until you have several applications, multiple environments, CI/CD, production servers and credentials that occasionally need to be rotated.

Then it starts becoming difficult to answer a surprisingly basic question:

Where is the current value of this secret?

I didn't want production secrets committed to Git.

I didn't want them baked into Docker images.

And I didn't particularly want a collection of .env.production files scattered around my servers either.

So I self-hosted Infisical and made secret management a separate part of the infrastructure.

This gave me another boundary that I really like:

GitHub    → code
GHCR      → images
VPS       → compute
Infisical → secrets

Each system has a clear responsibility.

The Docker image doesn't need to know where it is going to run when it is built.

It shouldn't contain production credentials.

Instead, configuration is provided when the application runs.

In practice, that means I can do something conceptually as simple as:

infisical run -- docker compose up -d

Infisical provides the environment and Docker Compose starts the application with it.

This also means I can use the same basic approach locally and on the server while keeping the actual values appropriate to each environment.


So what actually happens when I push?

Putting everything together, my deployment process looks roughly like this:

                 ┌──────────────┐
                 │    GitHub    │
                 └──────┬───────┘
                        │
                        │ push
                        ▼
               ┌──────────────────┐
               │  GitHub Actions  │
               └────────┬─────────┘
                        │
                        │ build
                        ▼
                  ┌──────────┐
                  │   GHCR   │
                  └────┬─────┘
                       │
                       │ pull
                       ▼
                  ┌─────────┐
                  │   VPS   │
                  └────┬────┘
                       │
               ┌───────▼────────┐
               │ Docker Compose │
               └───────┬────────┘
                       │
              ┌────────▼─────────┐
              │   Application    │
              │    Containers    │
              └──────────────────┘

                       ▲
                       │ secrets
                  ┌────┴─────┐
                  │ Infisical│
                  └──────────┘

GitHub Actions builds the image and publishes it to GHCR.

Once the build is successful, the deployment step connects to the VPS.

The server authenticates with GHCR and pulls the new image.

Then Docker Compose recreates the relevant containers using the configuration supplied through Infisical.

At its simplest, the server side of a deployment comes down to something close to:

docker compose pull
infisical run -- docker compose up -d

There are more checks and configuration around the real workflow, but I like the fact that the fundamental deployment process remains easy to understand.

Build somewhere else.

Store the image.

Pull it.

Run it.


Cloudflare handles the public side

At this point, the application is running.

But a container running on a server isn't particularly useful if nobody can reach it.

Cloudflare sits at the edge of my setup.

I use it for my domains and DNS, and traffic eventually makes its way from a public hostname to the appropriate service running on the VPS.

This gives me another useful abstraction.

Externally, a user sees:

app.example.com

Internally, that request might eventually reach something like:

my-api:3000

The networking in between is infrastructure.

The application doesn't need to care very much about how the user found it.

It also means one VPS can host multiple applications while each still has its own domain or subdomain.

From the user's perspective, they're completely separate products.

Underneath, they can share the same compute.


The whole thing is more boring than it sounds

When I explain the full setup, it can sound like there is a lot going on.

But day to day, there really isn't.

Most of the complexity is in setting it up the first time.

Once an application has its Docker configuration, Compose file, secrets and deployment workflow, my normal deployment experience is still basically:

git push

That's important to me.

I didn't move away from managed platforms because I wanted every deployment to become a DevOps exercise.

I wanted to do the infrastructure work once and then reuse it.

The best infrastructure is the infrastructure I don't have to think about every time I change a line of application code.


Of course, I gave something up

There is an obvious trade-off here.

When I use a managed platform, someone else is responsible for a lot of things.

With this setup, that person is me.

I have to think about server updates.

Disk space.

Backups.

Monitoring.

Container health.

Security.

Networking.

Certificates.

What happens when the VPS goes down.

What happens when a deployment fails.

There isn't a support team quietly operating the infrastructure underneath me.

So I definitely wouldn't argue that everyone should replace their hosting platform with a VPS.

Managed platforms are incredibly useful.

They let you focus almost entirely on the application, and that can easily be worth the money.

The calculation simply changed for me.

Once I was running multiple applications with APIs, workers, databases and other supporting services, I preferred paying for a pool of compute and accepting the operational responsibility that came with it.


The unexpected benefit wasn't the money

I originally started moving in this direction partly because hosting was becoming expensive.

The thing that made me keep going was everything I learned from doing it.

Before this, it was easy for deployment to feel like magic.

You connect GitHub to something, press deploy, and eventually a URL turns green.

Running more of the infrastructure myself forced me to understand what actually happens in between.

Now when something doesn't work, I have a much clearer mental model of the path:

Did the GitHub Action run?

        ↓

Did the Docker image build?

        ↓

Did it get pushed to GHCR?

        ↓

Could the VPS pull the image?

        ↓

Did Docker Compose start the container?

        ↓

Did the application receive its secrets?

        ↓

Is the container healthy?

        ↓

Is traffic being routed to it?

        ↓

Is DNS pointing to the right place?

Instead of "the deployment is broken", I have a series of boundaries I can inspect.

That has made me much more comfortable debugging production problems.

It has also changed how I design applications.

I think more about what is stateful and what isn't. Where data lives. How workers communicate with APIs. What should happen during a restart. What needs persistent storage. What configuration belongs in an image and what belongs in the environment.

Those are useful things to understand even if I eventually decide to deploy the application to a managed platform again.


I accidentally built a tiny platform

At some point I realised that I had essentially recreated a very small version of the thing I was originally paying hosting platforms for.

Not anywhere near the same scale, obviously.

But conceptually:

GitHub Actions  → Build system

GHCR            → Container registry

VPS             → Compute

Docker Compose  → Service orchestration

Infisical       → Secret management

Cloudflare      → Public edge

Put them together and I have a small deployment platform.

It doesn't autoscale.

It doesn't automatically distribute workloads across twenty servers.

It doesn't have sophisticated deployment strategies or a beautiful dashboard showing me green boxes.

And that's fine.

I'm not trying to rebuild AWS.

I'm trying to have a reliable place to run the applications I build.

For my current scale, this setup does that surprisingly well.


The part I value most is that it's reusable

When I start building something new now, deployment isn't a completely separate architectural decision.

I already have a path.

Containerise it.

Create the Compose configuration.

Add the secrets to Infisical.

Create the GitHub Actions workflow.

Push the image to GHCR.

Run it on the VPS.

Point the domain at it.

Done.

The applications themselves can be completely different, but the path to production stays familiar.

GitHub
   ↓
GitHub Actions
   ↓
GHCR
   ↓
VPS
   ↓
Docker Compose
   ↓
Application

Infisical  → Secrets
Cloudflare → Internet

What started as a response to rising hosting costs ended up becoming something I value for a completely different reason.

I understand it.

I know where my applications run.

I know how they get there.

I know where their configuration comes from.

I know how traffic reaches them.

And if I decide tomorrow that one piece of the stack no longer works for me, most of these tools can be replaced independently without throwing away the entire system.

I'm still paying for infrastructure.

I'm still accepting operational responsibility.

So I wouldn't call it free hosting.

What I have instead is something I find much more useful:

a deployment stack that I control, understand, and can keep reusing.


← Previous
I Thought Running My Own Email Server Would Save Money. It Cost Me a Week of My Life.