8Examples / blog
GitHub Actions · Docker · Self-hosting

How I Deploy to My Own Server with GitHub Actions

A push to main builds on GitHub’s machines. Production runs on mine. Two repositories, two Docker images, one registry, and a labelled self-hosted runner make the handoff explicit.

By Sean Bennett · August 30, 2026 · 11 min read

I use this pattern across a lot of small services, but Inventory Shopify is a good concrete example because it has two deployable processes. The Next.js backend serves the app and owns its SQLite data. A separate background processor polls the backend and does asynchronous Shopify work. They ship together, but they are not the same process and they should not be forced into the same container.

The interesting part is not any individual action. It is where each responsibility stops: the application repository proves it can manufacture runnable artifacts; the devops repository knows how my production machine is arranged; the container registry is the boundary between them.

APPLICATION REPOSITORY · GITHUB-HOSTEDPush to maininventory-shopifyBuild backendBuildx · GHA cacheBuild processorBuildx · GHA cacheGHCRSHA · branch · latestdispatchdevops repositoryinventory-shopify-deployMY INFRASTRUCTURE · SELF-HOSTED RUNNER [server7]Pull both imagesREAD_PACKAGES_PATStart backendport 3022 · durable dataStart processorpoll backend every 5 seconds
The registry is the artifact boundary; repository dispatch is only the release signal.

Build in the cloud, run at home

The first workflow lives in Inventory Shopify itself. A push to main starts two independent jobs on GitHub-hosted Ubuntu runners. One builds nextjs-app/Dockerfile; the other builds background-processor/Dockerfile. Because neither depends on the other, GitHub runs them in parallel.

Both jobs use Buildx and GitHub Actions cache, but with separate cache scopes. A backend layer change does not evict the processor’s useful layers. Each image also receives the Git commit and UTC build time as build arguments, so the artifact can identify the source that produced it.

The metadata action publishes several useful names: a branch tag, a commit-SHA tag, and latest on the default branch. Production currently pulls latest; the SHA tag remains available when I need to inspect or pin the exact artifact from a run.

Open the Inventory Shopify GitHub Action →

The deploy job waits for both artifacts

A third GitHub-hosted job has needs: [build-backend, build-processor]. If either build fails, there is no release signal. When both images are in GHCR, the job sends a repository_dispatch event to the separate devops repository.

needs: [build-backend, build-processor]
if: github.ref == 'refs/heads/main'

steps:
  - name: Trigger deployment
    run: |
      curl -f -X POST \
        -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" \
        https://api.github.com/repos/8exgh/devops/dispatches \
        -d '{"event_type":"inventory-shopify-deploy"}'

The dispatch does not copy an image and it does not SSH into anything. It says one thing: the artifacts for this release exist; run the production procedure. That lets the application workflow remain ignorant of ports, filesystem paths, tenant data, and runtime credentials.

Why the deployment is in another repository

Application repoHow to buildDockerfiles · source · tagsGHCRWhat to deployversioned container imagesDevops repoHow production runsports · volumes · secretsserver7Where it runsself-hosted runner · Docker
Source code does not need production credentials, and the deployment repository does not need to rebuild the application.

The split is mostly about ownership. The application repository contains source, Dockerfiles, and the knowledge needed to build. The devops workflow contains the topology of my infrastructure: which runner label selects the machine, which host port is free, where durable data lives, what the containers are called, and which repository secrets become environment variables.

It also means the same self-hosted runner can deploy many repositories without giving every application repository a shell script full of server details. GitHub brokers the job; the runner makes an outbound connection to GitHub and accepts work matching [self-hosted, linux, x64, server7].

The self-hosted runner is the deployment mechanism

The devops workflow is triggered by inventory-shopify-deploy. Every deployment job targets server7, a self-hosted Linux x64 runner installed on the physical machine that runs the containers. There is no separate SSH hop: when a workflow step says docker pull or docker run, it is already executing on that server.

runs-on: [self-hosted, linux, x64, server7]

docker run -d \
  --name sean-backend-inventory-shopify \
  --restart unless-stopped \
  -v /opt/inventory-shopify/data:/app/data \
  -p 3022:3000 \
  -e DATABASE_PATH=/app/data/system.db \
  ghcr.io/8exgh/inventory-shopify-backend:latest

The backend is published on host port 3022. Its database paths point inside /app/data, which is a bind mount from /opt/inventory-shopify/data on the host. Replacing a container therefore replaces code, not customer data. The workflow creates the system and tenant directories first and sets their ownership for the non-root process in the image.

Secrets are injected only here: JWT signing, the internal processor key, Shopify client credentials, OpenAI access for the processor, and the package-read token. They live in the devops repository’s Actions secret store and never become Docker image layers.

Order matters with two processes

The workflow stops both old containers first, including legacy pre-rename containers that could still hold port 3022 or keep polling Shopify twice. It then frees any remaining container bound to that port, pulls and starts the backend, waits, and only then starts the processor.

The processor calls the backend over the LAN at port 3022 and polls every five seconds. Starting it second is important: background work should not wake up against an API that is still being replaced.

Cleanup runs only after a successful processor deployment and prunes images older than 24 hours. The data volume is outside Docker’s image store, so pruning old layers does not touch SQLite.

The honest rough edge: readiness is still a timer

The workflow contains a proper retrying backend health check, but it is currently commented out with a note that the endpoint is not working. The active deployment waits twenty seconds instead. That is a known weak point: elapsed time is not evidence of readiness, and the processor job’s dependency comment promises more than the workflow currently proves.

The right next improvement is straightforward: repair a cheap health endpoint, poll it with a bounded retry loop, print container logs on failure, and refuse to start the processor unless the backend answers. After that, the next step would be the rollback pattern I use in newer deployments—keep the previous container until the replacement passes its health check, then remove it.

Why I like this shape

I do not need a hosted application platform to get a clean deployment boundary. GitHub’s runners do the expensive, disposable build work. GHCR stores the result. Repository dispatch crosses from application delivery into infrastructure delivery. My self-hosted runner performs a short, auditable list of Docker operations beside the workloads it manages.

The result is deliberately boring: push code, build two images in parallel, publish them once, signal production, pull on the target machine, mount the same durable data, inject secrets at runtime, and start processes in dependency order.

Build where compute is disposable. Store one artifact. Deploy where the data already lives.

Comments 0

No comments yet. Start the conversation.

Leave a comment

Site author? Sign in to reply officially.

Commenting is temporarily unavailable while CAPTCHA is being configured.