Dev Tools — Version Control, CI/CD & Registries
Key Concepts
Git internals that come up in interviews
- Every commit is a snapshot (not a diff), identified by a SHA-1 hash of its content, author, timestamp, and parent commit hash. This is why commits are immutable and content-addressable.
git rebasereplays commits on top of a new base, creating new SHAs.git mergecreates a merge commit. Neither is universally better — rebase gives a linear history, merge preserves branch topology.git cherry-pickapplies a single commit's changes to the current branch. Useful for backporting fixes.git bisectdoes a binary search through commit history to find which commit introduced a bug. Requires agit bisect good/git bisect badsignal — often a test script.
Code review best practices
Reviews should be < 400 lines of changed code per PR — larger PRs have significantly lower defect detection rates. The author's job is to make the reviewer's job easy: meaningful PR description, small atomic commits, tests included. The reviewer's job is to focus on correctness, not style (automated formatters handle style). LGTM without reading = the most common code review failure mode.
Testing pyramid
Unit tests (many, fast, no I/O) → Integration tests (fewer, slower, hit real dependencies) → End-to-end / UI tests (fewest, slowest, full stack). Inverting the pyramid (many E2E, few unit tests) makes CI slow and brittle. A common interview question: "where do you add coverage?" — start with units for logic, integration for DB queries, E2E only for critical happy paths.
Static analysis vs dynamic analysis
- Static analysis (Semgrep, ESLint,
mypy) inspects code without running it. Fast, catches bugs early, zero runtime cost. Can have false positives. - Dynamic analysis (ZAP, Valgrind, sanitizers) runs the code and observes behaviour. Catches runtime bugs that static analysis misses (memory corruption, race conditions) but requires execution.
Container build performance
Layer caching is the key lever. Layers that change rarely (OS packages, language runtime) go first; layers that change often (application code) go last. Using --mount=type=cache in BuildKit (or podman build --layers) caches package manager download directories (pip cache, npm cache) across builds, dramatically reducing build times for dependency-heavy projects.
Inner loop vs outer loop development
Inner loop = the tight cycle of edit-build-run on your laptop (code-server, hot reload, tilt up). Outer loop = the CI pipeline (Woodpecker, Forgejo Actions, GitHub Actions). Fast inner loops require good local tooling (kind, minikube, Tilt, Skaffold) so you're not waiting for CI. The goal: < 1 second feedback for syntax errors, < 10 seconds for unit tests, < 5 minutes for the full CI pipeline.
Service discovery patterns (Consul)
Services register with Consul on startup (name, address, port, health check endpoint). Clients query Consul's DNS (service-name.service.consul) or HTTP API to find a live instance — no hardcoded IPs. When a service fails its health check, Consul removes it from the registry automatically. This is how Nomad jobs find each other without environment variables or static config files. At work, you'll see this pattern in bare-metal microservice deployments, or as a sidecar in container environments that aren't on Kubernetes.
Nomad vs Kubernetes — when Nomad wins
Kubernetes is the dominant orchestrator, but Nomad handles workloads K8s can't: raw binaries, Java JAR files, Batch jobs with complex scheduling, and VM workloads. Nomad's job spec is simpler and more flexible than K8s manifests — a single binary, no etcd dependency. Teams already using the HashiCorp stack (Vault, Consul, Terraform) choose Nomad for its tight integration. Know both; most large shops run Kubernetes but may have legacy Nomad clusters.
Artifact management — why it matters
A container registry (Harbor, Nexus, Artifactory) isn't just storage — it's a security boundary. Pull-through proxy caches upstream images locally (faster builds, no DockerHub rate limits). Vulnerability scanning at push time (Trivy in Harbor) blocks vulnerable images from entering the pipeline. Image signing (cosign, Notary) provides provenance — the cluster can verify an image was built by your CI and not tampered with. In a regulated environment, pulling directly from DockerHub is a compliance finding.
SonarQube quality gates
A quality gate is a pass/fail threshold on code quality metrics — if new code introduces a coverage drop below 80%, a critical bug, or a security hotspot, the gate fails and the CI pipeline stops. This enforces quality as a pipeline concern rather than a post-merge review. The key metrics: Reliability (bugs), Security (vulnerabilities + hotspots), Maintainability (code smells, duplications), and Coverage. In interviews, distinguish SonarQube (code quality) from Semgrep (security patterns) — they complement each other.
Renovate vs Dependabot
Both auto-generate PRs to update dependencies. Renovate is more configurable: custom update schedules, grouping multiple dependencies into a single PR, auto-merging patch updates that pass CI, and monorepo support. Dependabot is simpler and built into GitHub. At scale, unmanaged dependencies become the largest source of CVEs — automated dependency updates with CI checks are the lowest-effort high-impact security practice.
Static analysis vs dynamic analysis — complementary, not interchangeable
SAST (Static Application Security Testing — Semgrep, Bandit, CodeQL) analyses source code without running it. Fast, runs in CI pre-merge, catches: SQL injection patterns, hardcoded secrets, insecure function calls, type errors. Cannot find: business logic flaws, runtime configuration issues, or vulnerabilities that only manifest under specific data conditions. DAST (Dynamic Analysis — OWASP ZAP, Nuclei) tests a running application from the outside, simulating an attacker. Catches: authentication bypasses, injection in live endpoints, misconfigured headers, rate limiting gaps. Cannot see: server-side code. SCA (Software Composition Analysis — Trivy, Grype) scans dependency manifests for known CVEs. All three are needed for a complete security posture — they find different classes of issues.
Git internals — what a commit actually is
A Git commit is a SHA-1 hash of: the tree object (a snapshot of all file contents at that point), the parent commit hash(es), author, committer, and the commit message. The tree recursively contains blob objects (file contents) and subtree objects (directories) — all content-addressed by hash. This means: (1) commits are immutable — changing any byte changes the hash and all descendant hashes; (2) identical file contents across branches are stored once (deduplication); (3) git checkout is just setting HEAD to point at a different commit object. Understanding this explains why rebasing "rewrites history" (creates new commit objects with new hashes) and why force-pushing after a rebase is destructive for collaborators.
Code review effectiveness — what to actually look for
Automated tools (linters, SAST, tests) should catch style, security patterns, and regressions before human review. Human review should focus on: (1) correctness of business logic — does the code actually do what the ticket says? (2) edge cases — what happens with empty input, concurrent access, or network failure? (3) API design — will this interface age well or require breaking changes in 6 months? (4) observability — are there enough logs and metrics to debug this in production? Avoid bikeshedding on style (automated formatters) or subjective naming (agree on a convention once, then stop discussing it).
Dependency management — why Renovate matters
Unpatched dependencies are the most common source of CVEs in production systems (OWASP A06). Renovate creates automated PRs to update package.json, requirements.txt, go.mod, Helm chart versions, and Docker base image tags on a schedule. Unlike Dependabot (GitHub-only, no grouping), Renovate works with Gitea/Forgejo, supports grouping multiple updates into one PR (reducing PR noise), and supports auto-merge for patch versions that pass CI. A Renovate PR with passing tests and a green Trivy scan can be merged with minimal review — this is how teams keep dependencies current without manual effort.
Gitea & Forgejo
Purpose: Lightweight, self-hosted Git servers with web UI, issue tracking, wikis, pull requests, and CI integration. Forgejo is a community-driven fork with identical CLI/API. Use Gitea/Forgejo as your private GitHub — complete with Actions-compatible CI.
# ~/gitea/compose.yaml
services:
gitea:
image: gitea/gitea:latest
ports:
- 127.0.0.1:3000:3000
- 127.0.0.1:2222:22
volumes:
- /home/user/gitea:/data:Z
environment:
USER_UID: "1000"
USER_GID: "1000"
restart: unless-stopped
cd ~/gitea && podman-compose up -d
Common operations
# Create an admin user via CLI
podman exec -it gitea gitea admin user create --username admin --password changeme --email admin@example.com --admin
# Reset a user's password
podman exec gitea gitea admin user change-password --username myuser --password newpassword
# List all users
podman exec gitea gitea admin user list
# Create an org
podman exec gitea gitea admin user create --username myorg --email org@example.com
# Run database migrations
podman exec gitea gitea migrate
# Regenerate git hooks (after upgrade)
podman exec gitea gitea admin regenerate hooks
# View logs
podman logs -f gitea
# Generate admin access token for API use
podman exec gitea gitea admin user generate-access-token --username admin --token-name mytoken
Configure SSH clients to use Port 2222 for git.home.local. After first login, configure your instance under the Site Administration panel (admin → Site Administration).
Woodpecker CI
Purpose: Simple, Gitea/Forgejo-native CI/CD engine. YAML pipeline configs live in the repo (.woodpecker.yml). Lightweight, fast, and compatible with the Drone CI pipeline format.
# ~/woodpecker/compose.yml
services:
woodpecker-server:
image: woodpeckerci/woodpecker-server:latest
ports: ["127.0.0.1:8000:8000"]
volumes: [woodpecker_data:/var/lib/woodpecker]
environment:
WOODPECKER_OPEN: "false"
WOODPECKER_HOST: https://ci.example.com
WOODPECKER_GITEA: "true"
WOODPECKER_GITEA_URL: https://git.example.com
WOODPECKER_GITEA_CLIENT: <oauth-client-id>
WOODPECKER_GITEA_SECRET: <oauth-client-secret>
WOODPECKER_AGENT_SECRET: changeme
restart: unless-stopped
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:latest
volumes:
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
- woodpecker_agent:/var/lib/woodpecker
environment:
WOODPECKER_SERVER: woodpecker-server:9000
WOODPECKER_AGENT_SECRET: changeme
depends_on: [woodpecker-server]
restart: unless-stopped
volumes: {woodpecker_data: {}, woodpecker_agent: {}}
cd ~/woodpecker && podman-compose up -d
code-server
Purpose: VS Code running in the browser with full terminal, extensions, and language support. Accessible from any device on your tailnet — develop on your server from a tablet, Chromebook, or low-powered laptop.
# ~/code-server/compose.yaml
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
ports:
- 127.0.0.1:8443:8443
volumes:
- /home/user/code-server:/home/coder:Z
environment:
PASSWORD: changeme
TZ: Asia/Kolkata
restart: unless-stopped
cd ~/code-server && podman-compose up -d
Caddy:
code.home.local { tls internal; reverse_proxy localhost:8443 }
Gitpod / Coder (Cloud Development Environments)
Purpose: Self-hosted cloud development environments. Each developer gets an isolated, pre-configured container workspace with their tooling, extensions, and dotfiles — reproducible from a Git repo. Coder is lighter and better for self-hosting; Gitpod requires more resources.
# ~/coder/compose.yaml
services:
coder:
image: ghcr.io/coder/coder:latest
ports:
- 127.0.0.1:3001:3000
volumes:
- /home/user/coder:/var/lib/coder:Z
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
environment:
CODER_ACCESS_URL: https://coder.home.local
CODER_WILDCARD_ACCESS_URL: *.coder.home.local
restart: unless-stopped
cd ~/coder && podman-compose up -d
Private Container Registry
Purpose: Store and serve your own container images. Useful for CI/CD pipelines that push images built by Woodpecker and pull them on deploy.
# ~/registry/compose.yaml
services:
registry:
image: registry:2
ports:
- 127.0.0.1:5000:5000
volumes:
- /home/user/registry/data:/var/lib/registry:Z
environment:
REGISTRY_STORAGE_DELETE_ENABLED: true
restart: unless-stopped
cd ~/registry && podman-compose up -d
Push an image to your registry
podman tag myimage localhost:5000/myimage:latest
podman push localhost:5000/myimage:latest
Add { "insecure-registries": ["localhost:5000"] } to /etc/containers/registries.conf to allow unverified pushes in development.
Mailpit (Email Testing)
Purpose: SMTP catch-all for development. All outgoing emails from your apps land in Mailpit's web UI — nothing is actually delivered. Perfect for testing Nextcloud, Gitea, or any app that sends email.
# ~/mailpit/compose.yaml
services:
mailpit:
image: axllent/mailpit
ports:
- 127.0.0.1:1025:1025
- 127.0.0.1:8025:8025
restart: unless-stopped
cd ~/mailpit && podman-compose up -d
Configure apps to use SMTP host localhost, port 1025. View emails at http://localhost:8025.
GitLab CE
Purpose: Full DevSecOps platform in a single container — Git hosting, CI/CD pipelines, container registry, merge requests, issue tracking, a package registry, Kubernetes integration, and secrets management. Heavier than Gitea/Forgejo (~4 GB RAM) but includes everything in one place, including built-in CI with GitLab Runners. The right choice when you want GitHub's full feature set self-hosted.
# ~/gitlab/compose.yml
services:
gitlab:
image: gitlab/gitlab-ce:latest
hostname: gitlab.example.com
ports:
- "127.0.0.1:8929:80"
- "127.0.0.1:8930:443"
- "127.0.0.1:2224:22"
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'https://gitlab.example.com'
gitlab_rails['gitlab_shell_ssh_port'] = 2224
gitlab_rails['time_zone'] = 'Asia/Kolkata'
nginx['listen_port'] = 80
nginx['listen_https'] = false
volumes:
- /home/user/gitlab/config:/etc/gitlab:Z
- /home/user/gitlab/logs:/var/log/gitlab:Z
- /home/user/gitlab/data:/var/opt/gitlab:Z
restart: unless-stopped
shm_size: "256m"
cd ~/gitlab && podman-compose up -d
GitLab requires at minimum 4 GB RAM — 8 GB recommended. First-start initialisation takes 3–5 minutes. Retrieve the initial root password with podman exec gitlab cat /etc/gitlab/initial_root_password.
Register a GitLab Runner
# ~/gitlab-runner/compose.yaml
services:
gitlab-runner:
image: gitlab/gitlab-runner:latest
volumes:
- /home/user/gitlab-runner/config:/etc/gitlab-runner:Z
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
restart: unless-stopped
cd ~/gitlab-runner && podman-compose up -d
SonarQube (Code Quality & Security)
Purpose: Static analysis platform for code quality and security scanning. Detects bugs, code smells, and security vulnerabilities (OWASP Top 10, CWEs) across 30+ languages. Integrates with Gitea, Forgejo, and GitLab CI as a pull request gate — failing builds when quality thresholds aren't met.
# ~/sonarqube/compose.yml
services:
sonarqube:
image: sonarqube:community
ports: ["127.0.0.1:9000:9000"]
environment:
SONAR_JDBC_URL: jdbc:postgresql://db:5432/sonarqube
SONAR_JDBC_USERNAME: sonarqube
SONAR_JDBC_PASSWORD: changeme
volumes:
- /home/user/sonarqube/data:/opt/sonarqube/data:Z
- /home/user/sonarqube/extensions:/opt/sonarqube/extensions:Z
- /home/user/sonarqube/logs:/opt/sonarqube/logs:Z
depends_on: [db]
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: sonarqube
POSTGRES_PASSWORD: changeme
POSTGRES_DB: sonarqube
volumes: [pg_data:/var/lib/postgresql/data]
restart: unless-stopped
volumes:
pg_data:
cd ~/sonarqube && podman-compose up -d
SonarQube requiresvm.max_map_count=524288andfs.file-max=131072on the host. Set them persistently:echo 'vm.max_map_count=524288' | sudo tee -a /etc/sysctl.d/sonar.conf && sudo sysctl -p /etc/sysctl.d/sonar.conf.
Access at http://localhost:9000. Default credentials: admin / admin (change on first login). Install the SonarScanner in your CI pipeline to push analysis results.
Harbor (Enterprise Container Registry)
Purpose: Cloud-native container registry with role-based access control, image vulnerability scanning (Trivy), image signing, replication between registries, and a web UI. A significant upgrade over the basic Docker Registry — Harbor gives you a proper private registry with security scanning built in. Ideal for CI/CD pipelines that push images built by Woodpecker or GitLab CI.
# ~/harbor/compose.yml — use the official installer (recommended)
# Download from: https://github.com/goharbor/harbor/releases
# wget https://github.com/goharbor/harbor/releases/download/v2.13.0/harbor-online-installer-v2.13.0.tgz
# Check https://github.com/goharbor/harbor/releases for the latest version
# tar xzvf harbor-online-installer-v2.13.0.tgz
# cd harbor && cp harbor.yml.tmpl harbor.yml
# Edit harbor.yml: set hostname, disable https (let Caddy handle TLS), set admin_initial_password
# Then: sudo ./install.sh --with-trivy
# Minimal harbor.yml changes:
# hostname: registry.home.local
# http.port: 8180
# https: (comment out entire section — Caddy handles TLS)
# harbor_admin_password: changeme
# database.password: changeme
cd ~/harbor && podman-compose up -d
Access at http://localhost:8180 after install. Default login: admin / your configured password.
Push images to Harbor
# Login
podman login registry.home.local
# Tag and push
podman tag myapp:latest registry.home.local/myproject/myapp:latest
podman push registry.home.local/myproject/myapp:latest
# Pull
podman pull registry.home.local/myproject/myapp:latest
Set up Woodpecker CI to push to Harbor
# .woodpecker.yml
steps:
build-and-push:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: registry.home.local
repo: registry.home.local/myproject/myapp
username:
from_secret: harbor_user
password:
from_secret: harbor_password
tags: [latest, "${CI_COMMIT_SHA}"]
Caddy:
registry.home.local { tls internal; reverse_proxy localhost:8180 }
Forgejo Actions Runner
Purpose: Native CI/CD runner for Forgejo (and Gitea) using the built-in Actions system. If you're already using Forgejo, this is the first runner to reach for — no separate Woodpecker server needed. Workflows live in .forgejo/workflows/*.yml (GitHub Actions-compatible syntax).
# ~/forgejo-runner/compose.yaml
services:
forgejo-runner:
image: code.forgejo.org/forgejo/runner:latest
volumes:
- /home/user/forgejo-runner:/data:Z
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
environment:
FORGEJO_INSTANCE_URL: https://git.home.local
FORGEJO_RUNNER_SECRET: changeme-from-forgejo-admin-panel
restart: unless-stopped
cd ~/forgejo-runner && podman-compose up -d
Register the runner in Forgejo
- Go to Site Administration → Actions → Runners → Create new runner in the Forgejo UI.
- Copy the registration token and set it as
FORGEJO_RUNNER_SECRET. - The runner registers itself on first start — refresh the Runners page to verify.
Example workflow
(.forgejo/workflows/ci.yml):
on: [push]
jobs:
test:
runs-on: docker
steps:
- uses: actions/checkout@v4
- run: echo "Hello from Forgejo Actions"
💡 Forgejo Actions runner uses the same act engine under the hood — most GitHub Actions marketplace actions work without modification.
Renovate Bot
Purpose: Automated dependency update pull requests for any repository. Renovate scans your repos for outdated container image tags, npm/pip/cargo packages, and GitHub Actions versions, then opens PRs with the exact diff. Self-hostable and works natively with Gitea and Forgejo. The self-hosted alternative to Dependabot.
# ~/renovate/compose.yaml
services:
renovate:
image: renovate/renovate:latest
environment:
RENOVATE_TOKEN: <your-gitea-personal-access-token>
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.home.local
RENOVATE_AUTODISCOVER: "true" # scan all repos the token can access
LOG_LEVEL: info
restart: "no" # run once per invocation; use a timer for scheduling
Run on demand or schedule with a systemd timer
# One-shot run
cd ~/renovate && podman-compose run --rm renovate
# Weekly timer
cat > ~/.config/systemd/user/renovate.service << 'EOF'
[Unit]
Description=Renovate Dependency Updater
[Service]
Type=oneshot
WorkingDirectory=/home/user/renovate
ExecStart=podman-compose run --rm renovate
EOF
cat > ~/.config/systemd/user/renovate.timer << 'EOF'
[Unit]
Description=Weekly Renovate Run
[Timer]
OnCalendar=weekly
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl --user enable --now renovate.timer
Minimal renovate.json to add to each repo root
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"],
"automerge": false
}
💡 Set "automerge": true for low-risk updates (patch versions) to have Renovate merge them automatically without your review.
Windmill (Workflow & Script Automation)
Purpose: Self-hosted alternative to n8n and Retool for code-heavy automations. Write scripts in Python, TypeScript, Bash, or Go; compose them into DAG workflows; build internal apps with a drag-and-drop UI; and schedule or trigger everything via webhook or cron — all version-controlled in Git. Significantly faster than n8n for automation tasks that are primarily code, not drag-and-drop.
# ~/windmill/compose.yaml
services:
windmill_server:
image: ghcr.io/windmill-labs/windmill:latest
ports:
- 127.0.0.1:8300:8000
environment:
DATABASE_URL: postgresql://windmill:changeme@db:5432/windmill
BASE_URL: https://windmill.home.local
MODE: server
depends_on: [db]
restart: unless-stopped
windmill_worker:
image: ghcr.io/windmill-labs/windmill:latest
environment:
DATABASE_URL: postgresql://windmill:changeme@db:5432/windmill
MODE: worker
WORKER_GROUP: default
depends_on: [db]
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: windmill
POSTGRES_PASSWORD: changeme
POSTGRES_DB: windmill
volumes: [pg_data:/var/lib/postgresql/data]
restart: unless-stopped
volumes:
pg_data:
cd ~/windmill && podman-compose up -d
Access at http://localhost:8300. Default credentials: admin@windmill.dev / changeme (change immediately). Create scripts, build flows, and deploy internal apps from the UI.
Caddy:
windmill.home.local { tls internal; reverse_proxy localhost:8300 }
Jenkins (Enterprise CI/CD)
Purpose: The most widely deployed open-source CI/CD server. Thousands of plugins covering every build tool, cloud provider, test framework, and deployment target. Common in enterprise environments where GitLab CI, Woodpecker, or Forgejo Actions aren't an option. Use Jenkins when you need to integrate with an existing org-wide pipeline or when the job description explicitly requires it.
# ~/jenkins/compose.yaml
services:
jenkins:
image: jenkins/jenkins:lts
ports:
- 127.0.0.1:8090:8080
- 127.0.0.1:50000:50000
volumes:
- /home/user/jenkins/data:/var/jenkins_home:Z
environment:
JAVA_OPTS: "-Djenkins.install.runSetupWizard=false"
restart: unless-stopped
cd ~/jenkins && podman-compose up -d
Get the initial admin password
podman exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
Common operations
# Install plugins via CLI (Jenkins CLI jar)
podman exec jenkins java -jar /var/jenkins_home/war/WEB-INF/jenkins-cli.jar \
-s http://localhost:8080 install-plugin git workflow-aggregator blueocean
# Restart Jenkins
podman exec jenkins java -jar /var/jenkins_home/war/WEB-INF/jenkins-cli.jar \
-s http://localhost:8080 safe-restart
# View logs
podman logs -f jenkins
# Reload configuration from disk
podman exec jenkins java -jar /var/jenkins_home/war/WEB-INF/jenkins-cli.jar \
-s http://localhost:8080 reload-configuration
Caddy:
jenkins.home.local { tls internal; reverse_proxy localhost:8090 }
💡 For greenfield projects prefer Woodpecker or Forgejo Actions — they're lighter and Podman-native. Use Jenkins when integrating with existing enterprise pipelines or when a job requires it specifically.
JFrog Artifactory OSS (Universal Artifact Repository)
Purpose: Universal artifact repository manager — store, proxy, and manage Maven, npm, PyPI, Docker, Helm, Go, Gradle, and generic binary artifacts from a single server. The self-hosted alternative to a paid Artifactory Cloud or GitHub Packages. Common in enterprise DevOps job descriptions alongside Jenkins or GitLab CI. OSS edition covers all common repository types.
# ~/artifactory/compose.yaml
services:
artifactory:
image: releases-docker.jfrog.io/jfrog/artifactory-oss:latest
ports:
- 127.0.0.1:8181:8082 # web UI and API
- 127.0.0.1:8182:8081 # legacy API
volumes:
- /home/user/artifactory/data:/var/opt/jfrog/artifactory:Z
environment:
JF_SHARED_DATABASE_TYPE: derby # built-in; swap for PostgreSQL in prod
restart: unless-stopped
cd ~/artifactory && podman-compose up -d
Default login: admin / password. Change immediately on first login.
Common operations
# Create a local repository via REST API
curl -u admin:password -X PUT \
"http://localhost:8181/artifactory/api/repositories/my-docker-local" \
-H "Content-Type: application/json" \
-d '{"rclass":"local","packageType":"docker"}'
# Push a Docker image to Artifactory
podman tag myapp:latest localhost:8182/my-docker-local/myapp:latest
podman push localhost:8182/my-docker-local/myapp:latest
# Upload a generic artifact
curl -u admin:password -T ./myapp.tar.gz \
"http://localhost:8181/artifactory/generic-local/myapp-1.0.tar.gz"
# Search for artifacts
curl -u admin:password \
"http://localhost:8181/artifactory/api/search/quick?name=myapp"
With PostgreSQL (production)
services:
artifactory:
image: releases-docker.jfrog.io/jfrog/artifactory-oss:latest
ports:
- 127.0.0.1:8181:8082
volumes:
- /home/user/artifactory/data:/var/opt/jfrog/artifactory:Z
environment:
JF_SHARED_DATABASE_TYPE: postgresql
JF_SHARED_DATABASE_URL: "jdbc:postgresql://db:5432/artifactory"
JF_SHARED_DATABASE_USERNAME: artifactory
JF_SHARED_DATABASE_PASSWORD: changeme
depends_on: [db]
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: artifactory
POSTGRES_PASSWORD: changeme
POSTGRES_DB: artifactory
volumes: [pg_data:/var/lib/postgresql/data]
restart: unless-stopped
volumes:
pg_data:
Caddy:
artifactory.home.local { tls internal; reverse_proxy localhost:8181 }
Nexus Repository OSS (Maven, npm, PyPI, Docker Proxy)
Purpose: Self-hosted artifact repository for Java/Maven, npm, PyPI, Docker, Helm, Go, NuGet, and RubyGems. Commonly used as a proxy/cache — pull from Maven Central or npm Registry through Nexus, reducing external bandwidth and enabling offline builds. The most common artifact manager in Java-heavy enterprise shops.
# ~/nexus/compose.yaml
services:
nexus:
image: sonatype/nexus3:latest
ports:
- 127.0.0.1:8091:8081 # web UI
- 127.0.0.1:8092:8082 # Docker registry port (configure in Nexus UI)
volumes:
- /home/user/nexus/data:/nexus-data:Z
restart: unless-stopped
cd ~/nexus && podman-compose up -d
# Get the initial admin password
podman exec nexus cat /nexus-data/admin.password
Common operations
# Configure Maven to use Nexus proxy — add to ~/.m2/settings.xml:
# <mirrors><mirror><id>nexus</id><url>http://localhost:8091/repository/maven-public/</url>
# <mirrorOf>*</mirrorOf></mirror></mirrors>
# Configure npm to use Nexus proxy
npm config set registry http://localhost:8091/repository/npm-proxy/
# Push a Docker image to Nexus
podman tag myapp:latest localhost:8092/myapp:latest
podman push localhost:8092/myapp:latest
# Upload a Maven artifact
mvn deploy -DaltDeploymentRepository=nexus::default::http://localhost:8091/repository/maven-releases/
# Clean up old blob store snapshots
# Nexus UI → Administration → Tasks → Create: "Delete unused components and assets"
Caddy:
nexus.home.local { tls internal; reverse_proxy localhost:8091 }
⚠️ Nexus requires at least 4 GB RAM for comfortable operation. Set-Xms2703m -Xmx2703min the JVM options via theINSTALL4J_ADD_VM_PARAMSenvironment variable if you need to cap memory usage.
Consul (Service Discovery & Service Mesh)
Purpose: HashiCorp's service discovery, health checking, key-value store, and service mesh. Services register themselves with Consul; others look them up by name rather than IP. Used in job descriptions for teams running Nomad, bare-metal microservices, or multi-cloud infrastructure. Pairs naturally with OpenBao (Vault) for dynamic secrets.
# ~/consul/compose.yaml
services:
consul:
image: hashicorp/consul:latest
ports:
- 127.0.0.1:8500:8500 # HTTP API & web UI
- 127.0.0.1:8600:8600/udp # DNS interface
volumes:
- /home/user/consul/data:/consul/data:Z
command: "agent -server -bootstrap-expect=1 -ui -client=0.0.0.0 -bind=0.0.0.0"
restart: unless-stopped
cd ~/consul && podman-compose up -d
Common operations
# Install Consul CLI via Nix
nix-env -iA nixpkgs.consul
# Check cluster members
consul members
# Register a service
consul services register -name=myapp -port=8080
# Watch for service changes
consul watch -type=services
# Read / write KV store
consul kv put myapp/config/db_host "db.home.local"
consul kv get myapp/config/db_host
# Check service health
consul health service myapp
# DNS lookup via Consul (requires 8600/udp)
dig @127.0.0.1 -p 8600 myapp.service.consul
Caddy:
consul.home.local { tls internal; reverse_proxy localhost:8500 }
Nomad (Workload Orchestrator)
Purpose: HashiCorp's flexible workload orchestrator — runs containers (Podman/Docker), VMs, Java JARs, raw binaries, and batch jobs. Simpler than Kubernetes for teams that don't need the full K8s ecosystem. Common in job descriptions at shops using the HashiCorp stack (Consul + Vault + Nomad). Pairs with Consul for service discovery and OpenBao for secrets.
# ~/nomad/compose.yaml
services:
nomad:
image: hashicorp/nomad:latest
ports:
- 127.0.0.1:4646:4646 # HTTP API & web UI
- 127.0.0.1:4647:4647 # RPC
- 127.0.0.1:4648:4648 # Serf (cluster gossip)
volumes:
- /home/user/nomad/data:/nomad/data:Z
- /home/user/nomad/config:/etc/nomad.d:Z
- /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro
cap_add: [SYS_ADMIN]
privileged: true
command: "agent -dev -bind=0.0.0.0 -log-level=INFO"
restart: unless-stopped
cd ~/nomad && podman-compose up -d
Common operations
# Install Nomad CLI via Nix
nix-env -iA nixpkgs.nomad
# Check node status
nomad node status
# Submit a job
nomad job run ~/nomad/jobs/nginx.nomad
# Check job status
nomad job status nginx
# View allocation logs
nomad alloc logs <alloc-id>
# Scale a job
nomad job scale nginx web 3
# Stop a job
nomad job stop nginx
Example job file (~/nomad/jobs/nginx.nomad)
job "nginx" {
datacenters = ["dc1"]
type = "service"
group "web" {
count = 1
network {
port "http" { static = 8099 }
}
task "nginx" {
driver = "docker"
config {
image = "nginx:alpine"
ports = ["http"]
}
resources {
cpu = 100
memory = 128
}
}
}
}
Caddy:
nomad.home.local { tls internal; reverse_proxy localhost:4646 }
Backstage (Internal Developer Portal)
Purpose: Spotify's open-source Internal Developer Platform. A single portal where developers discover services, APIs, documentation, pipelines, infrastructure, and runbooks — reducing cognitive load and onboarding time. Common in Platform Engineer and DevEx job descriptions. Integrates with Gitea/Forgejo, Kubernetes, ArgoCD, PagerDuty, Grafana, and hundreds of plugins.
# ~/backstage/compose.yaml
services:
backstage:
image: backstage/backstage:latest
ports:
- 127.0.0.1:7007:7007
volumes:
- /home/user/backstage/app-config.yaml:/app/app-config.production.yaml:ro,Z
environment:
NODE_ENV: production
APP_CONFIG_app_baseUrl: https://backstage.home.local
APP_CONFIG_backend_baseUrl: https://backstage.home.local
depends_on: [db]
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: backstage
POSTGRES_PASSWORD: changeme
POSTGRES_DB: backstage
volumes: [pg_data:/var/lib/postgresql/data]
restart: unless-stopped
volumes:
pg_data:
Minimal app-config.yaml
app:
title: Homelab Developer Portal
baseUrl: https://backstage.home.local
backend:
baseUrl: https://backstage.home.local
database:
client: pg
connection:
host: db
port: 5432
user: backstage
password: changeme
integrations:
gitea:
- host: git.home.local
token: ${GITEA_TOKEN}
catalog:
locations:
- type: url
target: https://git.home.local/myorg/catalog/blob/main/catalog-info.yaml
cd ~/backstage && podman-compose up -d
Caddy:
backstage.home.local { tls internal; reverse_proxy localhost:7007 }
💡 Backstage is most valuable once you have 5+ services. Start small — register a few services with catalog-info.yaml files in their repos, then add plugins incrementally (ArgoCD, Kubernetes, TechDocs).
Caddy Configuration
git.home.local { tls internal; reverse_proxy localhost:3000 }
ci.home.local { tls internal; reverse_proxy localhost:8000 }
code.home.local { tls internal; reverse_proxy localhost:8443 }
coder.home.local { tls internal; reverse_proxy localhost:3001 }
registry.home.local { tls internal; reverse_proxy localhost:5000 }
mail.home.local { tls internal; reverse_proxy localhost:8025 }
analytics.home.local { tls internal; reverse_proxy localhost:8500 }
pm.home.local { tls internal; reverse_proxy localhost:8600 }
crm.example.com { reverse_proxy localhost:3700 }
huly.home.local { tls internal; reverse_proxy localhost:8087 }
sign.home.local { tls internal; reverse_proxy localhost:3800 }
gitlab.example.com { reverse_proxy localhost:8929 }
sonar.home.local { tls internal; reverse_proxy localhost:9000 }
harbor.home.local { tls internal; reverse_proxy localhost:8180 }
plane.home.local { tls internal; reverse_proxy localhost:3009 }
windmill.home.local { tls internal; reverse_proxy localhost:8300 }
Gitea / Forgejo Webhooks
Webhooks let Gitea/Forgejo notify external services when events happen in a repository — a push, a pull request, a tag. This is how CI/CD pipelines get triggered without polling.
Configuring a webhook in Gitea
- Go to Repository → Settings → Webhooks → Add Webhook
- Choose Gitea (for native events) or HTTP (for generic JSON)
- Set the Payload URL to your CI endpoint (e.g.,
https://ci.home.local/api/webhooks) - Select which events trigger the hook (push, pull request, release)
- Gitea sends a signed HMAC-SHA256 payload — verify the
X-Gitea-Signatureheader in your receiver
Trigger a Woodpecker pipeline on push
# Woodpecker registers its webhook automatically when you enable a repo in the UI
# Verify the webhook appeared under Repository → Settings → Webhooks
# Test manually (simulate a push event)
curl -X POST https://ci.home.local/api/webhooks \
-H "X-Gitea-Event: push" \
-H "Content-Type: application/json" \
-d '{"ref": "refs/heads/main", "repository": {"full_name": "user/myrepo"}}'
Branch Protection and Merge Strategies
Branch protection: (Repository → Settings → Branches → Add Protected Branch) enforces quality gates before changes can land on important branches like main:
- Require status checks — CI must pass before merging
- Require pull request reviews — at least N reviewers must approve
- Restrict pushes — only specific users or teams can push directly
- Require signed commits — all commits must be GPG-signed
Merge strategies: affect what the git history looks like after a PR is merged:
| Strategy | What happens | Best for |
|---|---|---|
| Merge commit | Preserves all commits + adds a merge commit | Full history traceability |
| Squash and merge | All PR commits become a single commit on main | Clean history, easier git bisect |
| Rebase and merge | Replays PR commits on top of main, no merge commit | Linear history, no merge commits |
Linear history enforcement: (Squash or Rebase only) makes git log readable and git bisect reliable. Choose one strategy per repository and enforce it via branch protection — mixing strategies produces a chaotic history.
Secret Scanning
Accidentally committed secrets (API keys, passwords, tokens) are one of the most common security incidents. Add secret scanning as a CI step to catch them before they land on main.
gitleaks
scans for known secret patterns (AWS keys, GitHub tokens, generic high-entropy strings):
# .forgejo/workflows/secret-scan.yml
name: Secret Scan
on: [push, pull_request]
jobs:
gitleaks:
runs-on: docker
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history — gitleaks scans all commits, not just HEAD
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Or as a pre-commit hook (runs locally before you can even push):
# Install pre-commit
nix-env -iA nixpkgs.pre-commit
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
pre-commit install # installs the hook into .git/hooks/pre-commit
If gitleaks fires on a legitimate secret that's already been rotated and removed, add it to .gitleaksignore with a hash so future scans skip that specific finding.
Container Image Build Patterns
Minimal base images: reduce attack surface by shipping fewer packages. Less software = fewer CVEs = smaller Trivy scan results.
| Base image | Size | Use when |
|---|---|---|
debian:bookworm-slim | ~75 MB | Need a shell, standard tooling, apt packages |
alpine:3 | ~7 MB | Shell + musl libc; some binaries need recompilation |
gcr.io/distroless/static | ~2 MB | Statically compiled binaries (Go, Rust) — no shell at all |
scratch | 0 bytes | Fully static binaries, maximum minimalism |
Distroless: images have no shell, no package manager, no ls, no cat. You can't exec into them for debugging — use kubectl debug with a sidecar image instead. The tradeoff is worth it for production: no shell means no shell exploitation.
Layer caching in multi-stage builds
order your Dockerfile so the steps that change least frequently come first. Dependencies (which change rarely) before application code (which changes every commit):
FROM golang:1.23 AS builder
WORKDIR /src
# Copy go.mod first — cached until dependencies change
COPY go.mod go.sum ./
RUN go mod download
# Copy source second — cache is invalidated only when source changes
COPY . .
RUN go build -o /app ./cmd/server
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Using --cache-from in CI
pulls the previously built image to warm the layer cache across pipeline runs:
- name: Build
run: |
podman build \
--cache-from ghcr.io/myuser/myapp:cache \
--cache-to ghcr.io/myuser/myapp:cache \
-t ghcr.io/myuser/myapp:${{ github.sha }} .
Troubleshooting
| Issue | Solution |
|---|---|
| Gitea SSH push fails | Confirm client is using Port 2222 in ~/.ssh/config; check gitea user has write access to the data volume |
| Woodpecker agent not picking up jobs | Verify WOODPECKER_AGENT_SECRET matches on server and agent; check the agent has access to the Docker/Podman socket |
| code-server blank after login | Verify PASSWORD env var is set; check the port is not in use by another service |
| Private registry push rejected | Add { "insecure-registries": ["localhost:5000"] } to /etc/containers/registries.conf; restart Podman |
| Coder workspace fails to start | Confirm the Podman socket is mounted and accessible; check CODER_ACCESS_URL matches the URL you use to access it |
| n8n webhook not triggering | Ensure WEBHOOK_URL is the publicly accessible URL; check that Caddy is proxying correctly |
| code-server extension install fails | The container needs outbound internet access; verify network is not blocked by firewalld |
| Matomo setup wizard loops | Ensure MariaDB is fully started before Matomo; check MATOMO_DATABASE_HOST is db not localhost |
Matomo No data in reports | Verify the tracking snippet is correctly deployed on your site; check the Matomo real-time visitors page to confirm pings are arriving |
| Leantime blank after install | Run podman exec leantime php bin/leantime db:migrate to apply DB migrations; check podman logs leantime |
| Twenty CRM blank page | Ensure yarn database:migrate:prod ran successfully; check SERVER_URL matches the URL you access it from |
| Huly services not connecting | Use the official huly-selfhost compose stack which wires all services correctly; single-container mode is for testing only |
| DocuSeal PDF fields not saving | Ensure the /data volume has write permissions; check podman logs docuseal for storage errors |
| GitLab 502 on first load | Wait 3–5 min for full initialisation; check podman logs gitlab; ensure shm_size is set to at least 256m |
| GitLab Runner not picking up jobs | Verify the runner token matches; check runner tags match the job's tags: definition in .gitlab-ci.yml |
| SonarQube exits immediately | Set vm.max_map_count=524288 on the host with sudo sysctl -w vm.max_map_count=524288; add to /etc/sysctl.d/ to persist across reboots |
| Forgejo Actions runner not picking up jobs | Verify the registration token matches what's shown in Site Administration → Actions → Runners; confirm the Podman socket is mounted and accessible |
| Renovate PR not created | Ensure the token has write access to the repos; check podman logs on the renovate container for API errors; verify RENOVATE_PLATFORM=gitea is set |
| Windmill worker not executing jobs | Check DATABASE_URL is identical on server and worker; run podman logs windmill_worker for connection errors; ensure MODE=worker is set on the worker container |
Forgejo / Gitea Migration Patterns
When migrating repositories between instances or from GitHub/GitLab, use the built-in migration tool or the API:
# Migrate a GitHub repo into Gitea via API
curl -X POST https://git.home.local/api/v1/repos/migrate \
-H "Authorization: token YOUR_GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clone_addr": "https://github.com/myorg/myrepo",
"repo_name": "myrepo",
"repo_owner": "myorg",
"mirror": false,
"private": true,
"issues": true,
"labels": true,
"milestones": true,
"releases": true,
"wiki": true
}'
# Mirror a repo (keeps in sync with upstream)
# Change "mirror": true above — Gitea will pull new commits periodically
# Migrate all repos from a GitHub org (bash loop)
ORG=myorg
TOKEN=ghp_yourtoken
curl -s "https://api.github.com/orgs/$ORG/repos?per_page=100" \
-H "Authorization: token $TOKEN" \
| jq -r '.[].clone_url' | while read url; do
repo=$(basename "$url" .git)
echo "Migrating $repo..."
curl -sX POST https://git.home.local/api/v1/repos/migrate \
-H "Authorization: token YOUR_GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"clone_addr\":\"$url\",\"repo_name\":\"$repo\",\"repo_owner\":\"$ORG\",\"mirror\":false,\"private\":true}"
done
Additional Caddy Routes
draw.home.local { tls internal; reverse_proxy localhost:8710 }
Troubleshooting (additional)
| Issue | Solution |
|---|---|
| Gitea migration stuck | Check the Gitea admin logs; large repos may time out — increase timeout in app.ini under [migrations] |
| Woodpecker CI pipeline doesn't trigger | Verify the webhook is registered in Gitea (Repo → Settings → Webhooks) and that WOODPECKER_HOST is reachable from Gitea's network |
| Forgejo Actions runner shows as offline | Re-register with a fresh token from Site Administration → Actions → Runners; tokens expire after first use |