"Off-Grid Operator #8: Orchestrating Docker from a Satellite Connection"

docker satellite off-grid devops portainer self-hosted infrastructure

My cabin runs on Starlink. On a good day, that’s 100+ Mbps with reasonable latency. On a bad day — overcast, snowy, trees swaying into the signal path — it’s a satellite connection doing satellite things. Latency spikes to 600ms. Throughput drops. Sometimes it just disappears for thirty seconds.

Every piece of infrastructure I run has to work within those constraints. Not theoretically — actually.

The bandwidth tax on Docker

A fresh docker pull for a multi-stage Phoenix app image can be 200-400MB. That’s fine on fiber. On satellite, it’s a decision. Every pull costs time, and if the connection drops mid-layer, you get to start that layer over.

The first thing I learned: never pull images you don’t need. Pin versions. Don’t use latest tags — not because of reproducibility (though that too), but because latest means Docker can’t cache locally. Every deploy becomes a full pull instead of a layer diff.

My images are built on my town server, pushed to a private registry that’s also on my Tailnet. When the cabin pulls an image, it’s traveling over Tailscale — encrypted, direct, and not touching the public internet. The pull goes Wann server → Tailscale → cabin, and since both endpoints know each other, there’s no DNS resolution or TLS handshake to a registry CDN adding latency.

For images that come from Docker Hub or GitHub Container Registry, I pull them to the town server first, retag them, push to my private registry, then pull from there at the cabin. Extra step, but it means the cabin never talks to the public internet for Docker images. One less thing that breaks when Starlink is having a moment.

What you can’t do over satellite

Live-streaming a docker build from a remote machine doesn’t work well. The output is chatty — hundreds of lines per second during compilation steps. Each line is a round trip if you’re watching via SSH. Your terminal lags behind reality by seconds, then catches up in a burst, then lags again.

I don’t build at the cabin. I build on the town server or in CI, push the image, and pull at the cabin. Build and pull are separate operations that happen at separate times. The build doesn’t care about my connection. The pull is a single sequential download that handles interruptions gracefully — Docker resumes interrupted layer downloads.

docker compose up -d over SSH works fine. It’s a single command, the response is a few lines, and the actual work happens locally on the target machine. The satellite connection is only carrying the SSH session, not the container workload.

docker logs -f is borderline. Short bursts are fine. Long-running log streams accumulate enough latency to be useless. I tail logs in fixed chunks: docker logs --tail 50 container_name, look at what I need, move on.

Portainer as the satellite-friendly interface

This is where Portainer earns its place. The web UI is a single page load — HTML, JS, assets cached by the browser. After the initial load, every interaction is a small API call. Click to restart a container? That’s a tiny POST. Check container status? Small GET. View logs? Paginated, not streamed.

I run three Portainer endpoints: town server, cabin (CasaOS), and a cloud VPS. The Portainer instance itself runs on the town server. From the cabin, I open one browser tab and manage all three environments. The web UI is doing the same SSH-level operations I’d do from a terminal, but the interface is designed for request/response, not streaming. It works on satellite the way terminals don’t.

The CLI wrapper I built (portainer-query) does the same thing from scripts. portainer-query stacks lists everything. portainer-query restart-stack 14 restarts a specific stack. Each command is one HTTP request. The response comes back in under a second even on a bad connection day.

The caching philosophy

Every service I self-host, I ask: what happens when the internet goes away for ten minutes?

Home Assistant keeps running — all automations are local. The Zigbee devices don’t care about the internet. Battery monitoring, temperature sensors, relay controls — none of that needs a connection.

Mission Control keeps running — it’s on the local Tailnet, the database is local, the web UI is cached.

Open Brain keeps running — Postgres is on the same server.

What breaks: anything that calls an external API in real-time. LLM inference (needs OpenAI/Anthropic). Web searches. Email checks. Push notifications to external services.

The pattern is: local-first for state and control, cloud for intelligence and communication. When satellite drops, I can still see my battery level, still control my lights, still read my task board. I just can’t ask Claude to analyze anything until the connection comes back.

Deploy windows

I don’t deploy when Starlink is marginal. Not because it would technically fail — Docker handles interruptions reasonably well — but because I can’t verify the deploy properly. If the connection is choppy, I can’t load the web UI to confirm the new version is running correctly. I can’t check logs without lag. I can’t rollback quickly if something’s wrong.

Good connection → deploy → verify → done.

Bad connection → wait. The deploy isn’t urgent enough to risk a broken state I can’t diagnose.

This sounds obvious, but it took a few painful Saturday mornings to internalize. “It’s just a quick restart” turns into forty minutes of debugging when you can’t tell whether the problem is your code or your connection.

The actual workflow

Here’s what a typical infrastructure update looks like:

  1. Write code on my laptop (cabin, offline-capable)
  2. Push to GitHub when connection is good
  3. Town server builds the Docker image (CI or manual)
  4. Image pushed to private registry
  5. SSH to cabin server: docker compose pull && docker compose up -d
  6. Open Portainer, confirm containers are healthy
  7. Spot-check the app in the browser

Steps 1 and 2 can happen any time. Steps 3-7 happen during a good connection window. The whole deploy takes under five minutes if nothing goes wrong, and the failure mode for each step is “try again later” — not “data corruption” or “half-deployed state.”

Docker Compose with explicit image tags makes this possible. The compose file is the single source of truth for what’s running. docker compose up -d is idempotent. Run it twice, nothing changes. Run it after a failed pull, it picks up where it left off.

What I’d tell someone starting this

Don’t fight satellite. Design around it.

Build where the connection is reliable. Pull where the connection is unreliable. Make every remote operation idempotent so interruptions don’t create broken states. Use tools designed for request/response (Portainer, APIs) instead of tools designed for streaming (live logs, interactive shells).

The constraint sounds limiting, but it actually produces better infrastructure. If your deploy process works on satellite, it works everywhere. If your services run when the internet disappears, they’re genuinely resilient — not just “cloud-native resilient” where resilience means failing over to another cloud region.

And honestly? There’s something satisfying about running production infrastructure from a cabin in the Yukon, on a connection that bounces off satellites in low Earth orbit, managing containers that serve real users. The signal path is absurd. The systems are solid.


Building infrastructure that needs to work in constrained environments? Satellite, remote sites, unreliable connections — I build systems that handle it. Work with me →

Thoughts from the Yukon

© 2026 Andrew Kalek