RRitik.write
💻Technology•—views

Deploying to Remote Client Servers Without Inbound Access

We don't onboard a new client every day each one gets their own remote server, set up once and rarely touched again. Here's how self-hosted GitHub Actions runners let us deploy and maintain software on machines we cannot directly access, without opening a single inbound port.

Ritik SrivastavaRitik Srivastava
••8 min read
#DevOps#CI/CD#GitHub Actions#Docker#Self-Hosted Runners

Most deployment stories assume a shape that doesn’t apply to us: a fleet of servers you own, all running the same app, all reachable through the same VPN. That’s not our world.

We don’t onboard a new client every day a new deployment might happen once every few weeks, or once every few months. Each client gets their own physical remote server, sitting on infrastructure we don’t own, set up once, and then left to quietly do its job for a very long time.

Which means the thing we actually need isn’t a fleet management platform. It’s a way to update a server we’ll only think about occasionally, months apart, without forgetting how, and without needing to be physically anywhere near it.


What’s Already Running on the Box

Every one of these servers carries the same baseline stack, wired up with Docker Compose:

We also have our own desktop application that runs on the organization’s machines and communicates with the application services locally.

The important part is that the application itself is not exposed directly to the public internet. It runs locally, and the services on the server are not intended to become publicly accessible endpoints.

The same applies to the management and monitoring services.

Dozzle, Netdata, Dockge, and our application services are not independently exposed to the internet. They are part of the local/private setup and are accessed through the appropriate local application or nginx configuration.

So the normal application architecture looks roughly like this:

┌──────────────────────────────┐
│ Organization / Station       │
│                              │
│  Desktop Application         │
│          │                   │
│          │ local connection  │
│          ▼                   │
│  Local Application Services  │
│          │                   │
│          ├── Dozzle          │
│          ├── Netdata         │
│          └── Dockge          │
│                              │
└──────────────────────────────┘

       No direct public
       application access
       from the internet

This works well when someone is physically at the station or operating within the local network.

But it leaves us with a different problem.

How do we update that server when we are nowhere near it?


You Can’t TeamViewer Into a Server With No Desktop

Here’s the wall we kept hitting: these are headless Ubuntu boxes.

There’s no desktop to remote into in the first place TeamViewer and its cousins are built for a screen someone can look at, and there isn’t one.

Even if there were, “someone opens a remote desktop session and manually runs deployment commands” doesn’t scale to a pile of one-off client servers you touch a handful of times a year.

It’s not repeatable, it’s not auditable, and it depends entirely on someone remembering the exact steps from four months ago.

The application itself doesn’t solve this either.

Our desktop application is designed to work with the station’s local environment. It isn’t a remotely accessible management console sitting on the public internet.

So we had a very simple problem:

The application works locally. The server is physically remote. How do we deploy to it without opening remote access?


The Actual Insight: Stop Trying to Reach In

We spent a while thinking about this as an inbound-access problem VPNs, SSH tunnels, bastion hosts, someone’s laptop with a static IP allowlisted somewhere.

All of these approaches assume you can open a door into the client’s network.

That’s exactly the assumption that doesn’t hold when you don’t control that network and don’t want to ask a client to poke holes in their firewall for you.

The fix was to stop trying to reach in, and let the server reach out instead.

That’s precisely what a self-hosted GitHub Actions runner gives us: a small agent that sits on the client’s box and maintains an outbound connection to GitHub, where it receives jobs to execute.

No inbound port needs to be opened for the runner.

No VPN is required.

No one on the client’s network has to open a port for us.

The server initiates the connection outward, and GitHub sends the authorized workflow work back through that connection.

The deployment path therefore becomes completely separate from the application’s normal local communication path.

Normal application path:

Desktop Application
        │
        │ local/private network
        ▼
   Station Server
        │
        ├── Application
        ├── Dozzle
        ├── Netdata
        └── Dockge


Deployment path:

Station Server
        │
        │ outbound HTTPS
        ▼
     GitHub
        │
        │ workflow job
        ▼
Self-hosted Runner
        │
        ▼
 Docker Compose

How It’s Wired Up

We already had the basics for our own application:

.github/
  workflows/
    ci.yaml       # build, test, lint  the usual
    release.yaml  # cuts a release

On top of that, we added a client/ folder one deployment workflow per station, describing exactly how that specific server’s stack gets deployed and updated:

.github/
  workflows/
    client/
      station-1.yml   # deployment workflow for one station

station-1.yml isn’t a generic script.

It’s the actual recipe for that Ubuntu box: which services get restarted, which configuration gets loaded, and how the Docker Compose stack gets refreshed.

New station, new server, copy the deployment workflow, adjust it, and we’re ready.

It became configuration the same way our CSV parsing grammar did a new deployment stopped being “figure it out from scratch” and became “fill in the template.”


We Don’t Deploy Every Git Push

There is one important difference from a simple push-to-production setup:

We don’t deploy arbitrary commits directly to client servers.

Our deployment flow is release-based.

Code change
    │
    ▼
Local testing
    │
    ▼
Release tag
    │
    ▼
GitHub Actions
    │
    ▼
Station-specific workflow
    │
    ▼
Station's self-hosted runner
    │
    ▼
Docker Compose

The release tag represents a version that has already been tested locally.

The station runner then deploys the latest released tag rather than whatever happens to be sitting on a development branch.

This gives us a clean boundary between changing the product and deploying the product.


The Safety Boundaries

There are three things we use to keep the deployments separated:

1. A dedicated OS user for the runner

The runner doesn’t run as root.

It runs under a dedicated user with access to the deployment area.

Because the deployment needs Docker access, this user also has access to the Docker daemon. We treat that as privileged access rather than pretending Docker permissions are equivalent to ordinary filesystem permissions.

The important point is that we’re not running the entire deployment workflow as the server’s root account.

2. Dedicated deployment configuration and identity

Each station deployment has its own workflow configuration and dedicated GitHub bot identity.

The workflow for one station is therefore separated from the deployment configuration of another station rather than putting every station into one giant deployment script.

3. Dedicated GitHub Environment per deployment

Secrets, protection rules, and configuration live within the environment associated with that deployment.

This gives us another isolation boundary, so multiple stations don’t mean multiple sets of credentials and deployment configuration being mixed together.

These boundaries aren’t intended to claim that the system is magically isolated. The goal is to reduce the blast radius and make each deployment easier to reason about.


A Practical Safety Boundary While the Product Is Still Evolving

This setup is also deliberately designed around where the product is today.

We don’t have hundreds of stations that need sophisticated fleet orchestration.

We have a relatively small number of deployments where each server is important, changes are infrequent, and reliability matters more than deployment frequency.

So for now, we prefer explicit release tags, station-specific workflows, dedicated environments, and dedicated runners over trying to build a completely automated deployment platform.

That makes it easier to understand exactly what is being deployed, where it is going, and which credentials are involved.

As the number of deployments grows, we can evolve the architecture.

But we don’t need to solve a fleet-management problem before we actually have a fleet.


What This Actually Bought Us

Onboarding a new station now looks like this:

  1. Install the GitHub Actions runner once.
  2. Create its dedicated OS user.
  3. Configure the station-specific deployment workflow.
  4. Create its GitHub Environment.
  5. Configure the required deployment secrets.
  6. Run the runner on the station’s Ubuntu server.

After that, future deployments don’t require someone to remotely access the machine.

When we have a new version:

Developer
    │
    │ code changes
    ▼
Local testing
    │
    │ release tag
    ▼
GitHub Actions
    │
    │ station-specific workflow
    ▼
Station Ubuntu Server
    │
    │ self-hosted runner
    ▼
Docker Compose

No TeamViewer.

No VPN.

No inbound SSH access.

No deployment key sitting on someone’s laptop.

The server maintains an outbound connection to GitHub and waits for an approved release to be deployed.

And when we’re ready to release, we’re not asking:

“How do we get into that server again?”

We’re asking:

“Which tested release should this station run?”

That’s a much better problem to have.

Read something that made you go "wait, what?"

Message me directly — I read every one.

Say hi

that's a wrap!

Back to all articles