I have projects on my local machine that nobody else has seen. Not because the ideas were bad, but because getting from localhost to a real URL felt like too much work.

Setting up a server takes time. So does DNS, SSL, and deployment scripts. By the time it’s all done, the motivation is usually gone.

I wanted to fix that once. I spent some time building infrastructure where the only step after writing code is pushing it.


The free bit

Oracle Cloud has a free tier that doesn’t expire. AWS and Azure both cut off free access after 12 months. Oracle’s doesn’t. You get two ARM VMs with 1GB RAM each and real public IP addresses, indefinitely.

That’s not a lot, but it’s enough. One VM runs apps, the other runs the deployment tooling.


The architecture

Two servers, with Cloudflare in front.

Server 1 runs the apps. Everything I build runs here as a Docker container. Traefik handles routing and SSL. When a container starts with the right labels, Traefik requests a certificate from Let’s Encrypt and starts routing traffic to it.

Server 2 runs Forgejo, a self-hosted git platform. When I push code, a Forgejo Actions runner SSHes into Server 1 and deploys the app.

Cloudflare sits in front of Server 1. A proxied wildcard DNS record points to it. Every app subdomain goes through Cloudflare’s CDN. Anything that needs direct SSH access is set to DNS only — Cloudflare can’t proxy SSH.

My workflow:

  1. Create repo in Forgejo
  2. Clone it locally
  3. Write code, run it locally
  4. Push to main
  5. It’s live

Why Forgejo and not just GitHub?

GitHub would work. GitHub Actions can SSH into a server.

I used Forgejo because I wanted everything on my own infrastructure. It also runs in under 400MB RAM including the CI runner, which matters on a 1GB VM.

Forgejo is a fork of Gitea. It’s actively maintained. The Actions system uses the same syntax as GitHub Actions, so most workflows transfer with minor changes.


The deployment pipeline

On every push, the runner SSHes into the app server, pulls the latest code, rebuilds the container, and purges the Cloudflare cache for that subdomain. Secrets and hostnames are variables:

set -e
ssh-keyscan -p ${SSH_PORT} ${GIT_HOST} >> ~/.ssh/known_hosts
if [ -d "${APP_DIR}/${APP_NAME}/.git" ]; then
  cd ${APP_DIR}/${APP_NAME}
  git pull
else
  git clone git@${GIT_HOST}:${GIT_USER}/${APP_NAME}.git ${APP_DIR}/${APP_NAME}
fi
cd ${APP_DIR}/${APP_NAME}
docker rm -f ${APP_NAME} 2>/dev/null || true
docker compose down
docker compose up -d --build
docker image prune -f
docker builder prune -f
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data "{\"files\":[\"https://${APP_NAME}.${DOMAIN}\"]}"

set -e stops the script on any failure rather than continuing silently. docker rm -f before docker compose down removes orphaned containers from previous failed runs. The cache purge at the end tells Cloudflare to fetch fresh content rather than serve whatever it had cached.

The Cloudflare token, zone ID, hostnames, and SSH port are stored as org-level secrets in Forgejo. Every project can use them without putting credentials in the code.

A concurrency block in the workflow cancels any in-progress run when a newer push comes in.


Routing and certificates

Traefik watches Docker for new containers. When a container starts with these labels, Traefik requests a certificate from Let’s Encrypt and routes traffic to the container:

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.my-app.rule=Host(`my-app.gerardbeckerleg.com`)"
  - "traefik.http.routers.my-app.entrypoints=websecure"
  - "traefik.http.routers.my-app.tls.certresolver=myresolver"
  - "traefik.http.services.my-app.loadbalancer.server.port=8080"

Cloudflare picks up the new subdomain through the proxied wildcard record. It usually takes about 60 seconds from push to live.

To check Cloudflare is working, look at the response headers in devtools. cf-cache-status: HIT means the request was served from Cloudflare’s edge rather than the origin server.


What a new project looks like

Each project needs three files alongside the code:

  • A Dockerfile
  • A docker-compose.yml with the Traefik labels
  • A workflow file at .forgejo/workflows/deploy.yml

I’ve tested this with a .NET 8 minimal API and a Go HTTP server. .NET is slower — the SDK image is around 300MB and compiles slowly on ARM. Go compiles in a few seconds. First deploys take longer because Docker pulls base images, but subsequent deploys finish in under two minutes.


Things that went wrong

Setting this up took longer than expected.

Traefik threw a Docker API version error on first start. Traefik v3 defaults to an old API version for backwards compatibility. It needs an environment variable to override this. Using v2.11 instead avoids the issue entirely.

The Oracle VM went unresponsive twice during setup. SSH stopped responding and Oracle’s dashboard showed the instance as unresponsive. Oracle’s diagnostic reboot does a hard reset at the hypervisor level. It came back both times. Free tier ARM instances run on shared hardware and Oracle doesn’t guarantee stability. Adding a swap file helped — the .NET build can exhaust memory on a 1GB VM without it.

The cert resolver naming mismatch took a long time to spot. My Traefik config had a resolver called myresolver. The new app container referenced certresolver=letsencrypt. Traefik showed the router as active in the dashboard but didn’t issue a certificate because the resolver name didn’t match anything. One word to fix.

The Forgejo runner registration API changed. The current version ignores CLI flags and only reads configuration from environment variables. The error messages didn’t make this clear.

Git clone was silently using stale code. The initial clone was over HTTPS. Subsequent git pull calls would hang waiting for credentials that never arrived. Because the directory existed, the script took the git pull path, failed silently, and built whatever was already there. The fix was to delete the directory and re-clone over SSH using the deploy user’s key.

Private reusable workflows don’t work with the current act_runner. I tried putting the deploy logic in a shared private repo so each project could call it with one line. The runner ignores credential helpers, .netrc, and .gitconfig when cloning private repos internally. Keeping the script inline in each project works and is easier to follow.

Cloudflare broke SSH. The proxied wildcard record routed all traffic through Cloudflare, including SSH to the app server. Cloudflare doesn’t proxy SSH so the connection timed out. The fix was to set any hostname needing SSH access to DNS only and keep the * wildcard proxied for app subdomains. As a side effect, the wildcard also means individual app subdomains don’t expose the origin IP.


Security

The app server has a dedicated deploy user with Docker group membership. The CI runner uses only that user. If the deploy key were compromised, an attacker could redeploy apps but not much else.

Forgejo has REQUIRE_SIGNIN_VIEW = true. The git server isn’t publicly browsable. Self-registration is off.

SSH accepts public key authentication only, on a non-standard port, with root login disabled. Bots were attempting common usernames on port 22 within minutes of the server starting. They don’t get in.

MFA is enabled on the Forgejo account via TOTP. A compromised git account would give access to the deployment pipeline, so a second factor made sense.

Email goes through Resend’s SMTP relay so Forgejo can send notifications and password resets.


What it supports

Anything with a Dockerfile. Static sites, .NET APIs, Go services, Node apps, Python services.

SQLite databases persist between deploys using Docker volumes. Postgres can be added as a second service in docker-compose.yml.

The subdomain is the repo name. A repo called recipe-tracker ends up at recipe-tracker.gerardbeckerleg.com. Multiple projects can run at the same time with no routing config needed per project.


The cost

Server 1: $0. Oracle Always Free ARM VM. Server 2: $0. Oracle Always Free ARM VM. Domain: ~$20 AUD/year. SSL certificates: $0. Let’s Encrypt, automated. CI/CD: $0. Self-hosted runner. CDN and DDoS protection: $0. Cloudflare free tier. Email: $0. Resend free tier.

The only cost is the domain.


Is it production-ready?

No. There’s no redundancy, no automated backups, no load balancing. If Oracle’s Sydney data centre has a problem, the sites go down.

For personal projects that doesn’t matter. The goal was to reduce the work between having an idea and having it running somewhere. That’s done.


What’s next

A backup strategy for the Forgejo data and any persistent databases. If the git server dies, the repos go with it.

A personal website. It’s been on the list for a while.

More projects.