A Foray into Private Hosting

I'm going to revolutionize the way you host software on the internet! Follow along as I build in the open - starting with a private git server run by Forgejo and networked with Tailscale. Follow and stay tuned for more!

I’ve been on a campaign for a few months to redefine how software is hosted on the internet. It’s been a decade since I worked in the enterprise and nothing much has changed.

It’s beyond time for that change to happen.

For a first example I chose to tackle my git hosting.

GitHub, GitLab, and Friends

I absolutely love GitHub. It was how I finally learned how to use a distributed version control system (dVCS) over the centralized systems like Subversion and Team Foundation that I’d learned earlier in my career. The only thing I don’t love about it is the fact that it’s a centrally-hosted tool. I can’t event run it myself without an expensive enterprise license

My GitHub contributions for the past year have been somewhat sparse due to job changes and swapping from GitHub to GitLab in the process.

I’ve used GitLab in a few organizations and it’s a powerful alternative. You can host it yourself as an enterprise for a cost, and it has somewhat more powerful tools than GitHub alone. Self-hosting is also both permitted and fairly common … but not for the feint of heart due to the complex configuration. Especially when you get into CI/CD integration.

GitLab contribution graph reflecting late 2024-early 2025 contributions.
My GitLab contributions have been remarkably steady since starting a new role that exclusively uses the tool.

Finally, I played a bit with Gitea. It’s (mostly?) open source and remarkably easy to configure. I don’t have any problems with the software whatsoever and ran it personally for a few months myself. Recently, though, I switched over to Forgejo due to some more comprehensive documentation on integrating my git server with Tailscale.1There’s no functional difference between Forgejo and Gitea that I can see as a user. The former is a fork of the latter and, under the hood, they still run a lot of the same code. That said, they’ve been gradually diverging over time so your mileage may vary …

Taking git Private

I’ve used Tailscale for a while now to network all of my devices together. My primary desktop, my laptops, my phone, the NUCs running my personal cloud, my mail server. Every device in my stack is configured to connect into my personal Tailnet, giving me the ability to leverage exit nodes for IP routing and services hosted for my eyes only.

My personal NextCloud server runs on a machine on my desk, backs up to a private cloud instance, and lets me sync and back up my machines and mobile device regularly. I wanted to do the same with Forgejo, and it was much easier than I expected, thanks to a Forejo-hosted example Docker configuration.

The project README explains how things can be built by default. My installation consists of:

  • A Forgejo instance connected directly to my Tailnet
  • A pre-configured GitHub-style action runner for CI/CD
  • Mail delivery via AWS SES

My installation differs from the example mostly by bumping to the latest versions of the forgejo/forgejo and forgejo/runner images but otherwise follows the stock example to the letter.

A private instance of Forgejo hosting its own configuration
My Forgejo configuration hosted itself on my private instance of Forgejo accessible only to devices on my private Tailnet.

Private CI/CD and Packages

The private hosting of code is only part of the journey. While all of my devices can talk to the code repository, a stronger story would be integrating proper CI/CD pipelines and allowing for built artifacts to be conveyed over my Tailnet as well. This was, unfortunately, too difficult a journey with my older Gitea setup due to the way I’d configured the host.

Forgejo provided the opportunity to begin again!

I started with the simplest of Dockerfiles defining a basic “hello world” container, aiming to package a container image and deliver it to a remote machine directly.

FROM alpine:latest

CMD ["echo", "hello world!"]

The hardest part wasn’t the build itself, it was appropriately tagging and pushing the subsequent images into my private repository. This was difficult due to the fact that the repository itself is provided by Forgejo and only on my Tailnet – so any ephemeral container run by the CI/CD pipeline needs to also be present on the Tailnet in order to talk to the repository!

After some trial and error I was able to identify a fork of the stock Tailscale build action, along with the appropriate Docker Buildx option to properly set DNS within the build container. This allowed for my hello world application to build, tag, push …

name: Build Images
run-name: ${{ gitea.actor }} is running ci pipeline
on: [ push ]

jobs:
  build:
    runs-on: ubuntu-22.04
    if: gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/actions'
    steps:
      - name: Checkout
        uses: https://github.com/actions/checkout@v4
      - name: Set outputs
        id: vars
        run: |
          echo "commit=$(git log -1 --format='%H')" >> $GITHUB_OUTPUT
          echo "branch=$(git rev-parse --abbrev-ref HEAD)" >> $GITHUB_OUTPUT
          echo "gitref=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
      - name: Connect to Tailnet
        uses: https://github.com/Pikachews/tailscale-action@v1
        with:
          oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
          oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
          tags: tag:ci        
      - name: Login to Docker Registry
        uses: https://github.com/docker/login-action@v3
        with:
          registry: git.[my-tailnet].ts.net
          username: ericmann
          password: ${{ secrets.DOCKER_TOKEN }}
      - name: Set up Docker Buildx
        uses: https://github.com/docker/setup-buildx-action@v3        
        with:
          driver-opts: |
            network=host
      - name: Build and push Docker image
        uses: https://github.com/docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            git.[my-tailnet].ts.net/ericmann/ci-test:latest
            git.[my-tailnet].ts.net/ericmann/ci-test:${{ steps.vars.outputs.branch }}
            git.[my-tailnet].ts.net/ericmann/ci-test:${{ steps.vars.outputs.gitref }}

… and eventually pull the generated Docker container artifacts!

$ docker run --rm git.[my-tailnet].ts.net/ericmann/ci-test:latest
Unable to find image 'git.[my-tailnet].ts.net/ericmann/ci-test:latest' locally
latest: Pulling from ericmann/ci-test
f18232174bc9: Already exists 
Digest: sha256:170cf57f399eaf7d8ed4ad1590eddaab5ae24ce608bae48d55ead959cc51468b
Status: Downloaded newer image for git.[my-tailnet].ts.net/ericmann/ci-test:latest
hello world!

OK, Geek. What’s Next?

Me hosting a private git repository doesn’t do you any good. The fact that I had this entire stack up and running in less than 20 minutes does.

Over the next several months I’m going to be packaging my configurations into a single tool you can use to automate your own infrastructure. Do you want your own private git server? Your own container registry? Your own blog? Your own Mastodon instance? A private Matrix server for friends and family?

My plan is to provide all of this and more. A complete, DIY infrastructure kit for solo developers and small businesses who lack formal SRE capabilities. I’ll be building in the open, so you can keep track of progress:

Follow and stay tuned for more!

  • 1
    There’s no functional difference between Forgejo and Gitea that I can see as a user. The former is a fork of the latter and, under the hood, they still run a lot of the same code. That said, they’ve been gradually diverging over time so your mileage may vary …