Skip to content
DnsLister Forum

Where domain hunters compare notes

How I maintain my homelab with Docker Compose, Gitea and AI

TL;DR: My Ubuntu and Synology homelab is managed as code with Docker Compose and Gitea Actions. Templates, validation, health checks, automatic deployments and rollback keep it predictable, while OpenChamber lets me use AI from my computer or phone to deploy new stacks, inspect containers and logs, diagnose problems and monitor Gitea Actions.

Like many homelabs, mine started with a few containers and a collection of commands I was certain I would remember six months later.

I did not remember them.

As the lab grew, adding an application became a familiar ritual: find an unused port, create some directories, copy an old Compose file, update DNS, add a reverse-proxy entry, configure secrets and hope I had not forgotten a health check.

None of this was difficult, but it was repetitive and easy to get subtly wrong. I wanted Git to describe what should be running, Gitea to deploy it, and the repository to catch mistakes before they reached the server.

That is now how most of the lab works. If I reuse a port, omit a required variable or forget a health check, validation fails before deployment. If an updated container does not become healthy, the deployment restores the previous image.

It also works extremely well with AI tools. To add something, I can usually say:

Deploy a new stack named xxx using https://github.com/owner/app. 

The repository already defines what “deploy” means. It contains an AGENTS.md with the project rules, a Compose template, a Gitea workflow template, reusable deployment scripts and automated validation.

The AI reads the application’s documentation, examines similar stacks and follows the established pattern. It chooses an unused port, creates persistent storage, adds a health check, creates the deployment workflow, updates the port inventory and runs the validation commands. I also host OpenChamber, which gives me a web interface for working with the repository. I can access it from a computer or my phone, so I am not limited to starting these jobs from my desk.

OpenChamber has access to the host Docker instance and includes the Docker and Gitea command-line tools. That means the AI is not limited to editing YAML. It can inspect running containers, check health status, read logs, render Compose configuration and diagnose deployment failures. It can also interact with Gitea and monitor validation and deployment actions.

For example, if something stops working, I can open OpenChamber on my phone and ask it to investigate. It can inspect the container, check recent logs, compare the running state with the repository and look at the relevant Gitea Actions run. If the problem requires a configuration change, it can prepare the fix and run the same validation used by CI.

The homelab itself is split between an Ubuntu Server Docker host, let's call it MyServer, and a Synology NAS. Ubuntu runs most containers and a few native workloads. The Synology provides storage, snapshots and virtualization, with Home Assistant running in a dedicated VM.

The network is currently a flat LAN without VLANs. The router handles DHCP and advertises two AdGuard Home resolvers. The primary runs as a Home Assistant add-on, while the replica runs in Docker on MyServer.

An adguardhome-sync container copies the primary configuration to the replica every night. Both resolvers use encrypted upstream DNS through DoT or DoH. Either machine can therefore be taken offline without breaking DNS for the house.

Local applications use .home names. AdGuard rewrites those names to MyServer, where Caddy provides local HTTPS and routes requests to containers, native Ubuntu services, the Synology or Home Assistant.

Remote web access uses Cloudflare Tunnel. One cloudflared connector runs on MyServer, while another runs on the Synology. Because they establish outbound connections, I do not need to forward application web ports through the router. Full LAN access is handled separately through OpenVPN on the Synology.

The Docker repository has a predictable layout:

MyServer/ docker/ <stack>/ docker-compose.yml Dockerfile config/ .gitea/ actions/ deploy-stack/ scripts/ workflows/ MyServer/ <stack>.yml 

Every managed stack has a Compose file and a matching Gitea Actions workflow. Infrastructure stacks include AdGuard Home, Caddy, Cloudflared, Gitea, the Gitea Actions runner, Docker socket proxies, Beszel, Duplicati and OpenChamber.

Compose files follow the same basic pattern:

x-common-keys: &common-keys networks: - example security_opt: - no-new-privileges:true restart: unless-stopped x-common-logging: &common-logging driver: json-file options: max-size: "10m" max-file: "3" name: example networks: example: external: true services: example: image: vendor/example:1.2.3 container_name: example ports: - "8080:8080" volumes: - ${DOCKERDIR:?DOCKERDIR is required}/example:/data healthcheck: test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/ >/dev/null"] interval: 30s timeout: 5s retries: 3 start_period: 60s logging: *common-logging <<: *common-keys 

Every stack normally gets its own external Docker network. Related stacks can intentionally share one, but that is the exception.

Docker only permits a limited number of automatically allocated bridge networks by default. Because this design creates many networks, I increased the number available in Docker’s daemon-level address-pool configuration. Otherwise, Docker eventually fails with:

could not find an available, non-overlapping IPv4 address pool 

Persistent data lives under one stable root, normally ${DOCKERDIR}/<service>. Required values use syntax such as ${DOCKERDIR:?DOCKERDIR is required}, so Compose fails immediately if configuration is missing.

Every long-running container has a health check. Docker logs are capped at three 10 MB files per container, preventing a noisy application from filling the disk. Most containers also have CPU and memory limits.

Every published host port is recorded in allocated-ports.md. CI compares the rendered Compose configuration with that inventory and fails if ports collide, a binding is undocumented or the document contains a stale reservation.

The individual media applications are outside the scope of this post, but Gluetun acts as their WireGuard gateway. Containers that require the VPN share Gluetun’s network namespace and wait for it to become healthy before starting. This makes VPN routing part of the container topology rather than an application setting that could be accidentally disabled.

Docker socket access is separated by purpose. Read-only consumers use a restricted docker-socket-proxy. The Gitea runner uses a separate read-write proxy that can build images and manage containers. OpenChamber has direct Docker access because diagnosing and managing the host is one of its intended jobs.

A repository-level ./dev script provides the local validation interface:

./dev validate ./dev validate-stack MyServer example ./dev check 

Behind those commands are Compose rendering, convention checks, port validation, workflow linting, documentation checks and ShellCheck.

A pre-push hook runs the same checks as Gitea. The Compose linter requires every service to declare no-new-privileges:true, a restart policy, bounded logging and either a health check or an explicit batch-job exception.

When I push to main, Gitea validates the entire repository. If validation succeeds, a script looks at the Git diff and dispatches only the workflows for stacks that changed. A change to shared deployment tooling triggers every stack that depends on it.

The deployment action creates a temporary .env, checks required values, creates persistent directories, validates the Compose configuration, runs Trivy, pulls or builds images and recreates the stack.

Secrets come from Gitea Actions. Sensitive values that should not appear in .env are exported directly into the Compose process. The temporary file is ignored by Git and removed after the workflow.

After deployment, the action waits for each container to become healthy. If a container exits or never becomes ready, the workflow prints its recent logs.

Before updating, the action records the image IDs currently used by the stack. A failed deployment retags those images, recreates the previous containers and verifies their health again. This only rolls back images; it cannot undo configuration changes or database migrations.

Successful workflows can also register local DNS records through the AdGuard API, keeping the deployed stack, Caddy routing and local DNS in sync.

Gitea and its runner are the bootstrap exception because they cannot safely recreate themselves from their own workflow. Their workflow validates and scans the stack but stops before deployment. A host-side script recreates them using configuration extracted from the existing containers.

Renovate runs through Gitea Actions every four hours. It tracks Compose images, Dockerfile base images and workflow dependencies. Minor and patch updates can merge automatically after validation, while major updates remain manual.

Trivy scans custom Dockerfiles for configuration problems during deployment. Image vulnerability scans are advisory because upstream images often contain findings without available fixes.

A separate weekly audit compares image findings with a committed baseline and opens a pull request when they change. I can then update the image or explicitly accept the new baseline.

A UPS protects the network equipment, Ubuntu host and Synology. It connects to the Synology over USB, and automatic shutdown is configured for low battery conditions.

Beszel provides internal host and container metrics. Externally, the hosts send five-minute heartbeats to Healthchecks.io and are independently checked by UptimeRobot. Monitoring needs to remain available when the homelab is not.

Authentication uses Microsoft Entra ID through OpenID Connect wherever possible. Applications without OIDC require their own two-factor authentication.

The setup still has weaknesses. The LAN is flat, some container ports are directly reachable from the local network, Docker access remains highly privileged, and Gitea has a manual bootstrap path.

Even with those limitations, operating the lab is much easier than it used to be. I no longer need to remember how I deployed something two years ago because the repository remembers for me.

Docker Compose runs the containers, but the pleasant part comes from everything around it: templates, stable paths, health checks, port tracking, validation, automatic deployments, rollback, backups and external monitoring.

Once those foundations were in place, AI-assisted administration became almost a side effect. I can open OpenChamber from my phone, ask it to deploy something or investigate a failure, and let the same repository guardrails keep the result consistent.

Source: r/selfhosted · by /u/atika

Leave a Reply

Your email address will not be published. Required fields are marked *