3 Painful Lessons from Deploying a Stock App on a Home Server
iptime CAA block, DB port conflict, stale manual checkout — things you only learn the hard way
I set up a GitHub Actions self-hosted runner on my home server and wired up automatic deployments for my stock app using Caddy + DuckDNS. The architecture itself is straightforward, but once I actually tried to bring it up, I kept hitting walls in places I never expected. Here are the three times I got stuck — and how I got unstuck.
Lesson 1: One line — dig +short CAA iptime.org — solved two days of pain
My original plan was to get a Let’s Encrypt certificate for korat.iptime.org. I opened port 80/443 forwarding, got the Caddy config right, even set up a DuckDNS updater — and the certificate still wouldn’t come.
The only thing showing up in the Caddy logs was:
challenge failed ... error:caa ... "prevents issuance"
I suspected the port forwarding first, then went through the checklist: public IP, double NAT, ISP blocking — everything checked out fine. On day two, I finally thought of CAA records and tried just this one command:
dig +short CAA iptime.org
Result: 0 issue ";". A record that flat-out blocks every public CA from issuing certificates. Since the iptime.org parent domain DNS isn’t something I can control, there was no way to fix it.
The fix was switching to DuckDNS. No CAA record, built-in DDNS, free. I changed one environment variable — SITE_DOMAIN — swapped the domain, and certificate obtained successfully appeared almost immediately.
Going forward: Whenever I use a new domain, the very first thing I check is dig +short CAA <domain> and dig +short CAA <parent-domain>. Checking only the subdomain isn’t enough. If the parent domain is blocked, the subdomain is blocked too.
Lesson 2: The self-hosted runner and the live DB were sharing the same port
The self-hosted runner was also the deployment target. With the live dev stack (trading_db_dev) always running on the same home server, every time CI ran it would try to spin up a fresh test DB container — on the same machine.
Every dev-release push broke CI with:
Bind for 127.0.0.1:5432 failed: port is already allocated
At first I thought it was leftover containers from a previous run, but that wasn’t it. The live dev stack’s trading_db_dev was permanently holding 127.0.0.1:5432, and CI’s trading_db was trying to bind on top of it at 0.0.0.0:5432 — a structural conflict. The clue was that the error log pointed specifically at 127.0.0.1:5432, not 0.0.0.0.
Once I understood the cause, the fix was obvious. The CI containers never needed host port publishing in the first place — communicating over the internal container network was enough. I created a docker-compose.ci.yaml override and used ports: !reset [] to remove host publishing entirely.
(The !reset syntax requires Compose v2.24 or later. A plain [] doesn’t actually clear existing entries — it just concatenates.)
Going forward: When the self-hosted runner is also the deployment target, CI temporary stacks and live stacks must not share host ports. CI-only stacks should stay on the internal network.
Lesson 3: One stale manual checkout took down the site
Even after CD was in place, I still had the old habit of deploying manually. I ran docker compose up from a ~/trading_mvp folder I’d cloned by hand on the server. Errors came one at a time, in sequence.
invalid proto:— there was a trailing colon typo in a port definition.Network deploy_default Created— the stale file had noname:pin, so the project name defaulted to the directory namedeployinstead of what CD expected."trading_web_dev" is already in use— container name collision between my manual stack and the CD-managed stack (trading-mvp-dev).Can't locate revision— the stale checkout was missing the latest alembic migrations.RuntimeError: PII_ENCRYPTION_KEY is not set— the key was missing frombackend.env, so migrate failed fast, and backend, worker, beat, and caddy — all waiting on migrate — got stuck atCreated. Site down.
The CD-managed copy lives under ~/actions-runner/_work/... so the live stack itself was fine, but the stale manual folder tried to bring up another stack with the same container names and triggered a cascade of conflicts.
The fix was deleting the manual folder. Data lives in a bind mount at /srv/trading-mvp/data so it was preserved when I removed the folder.
Going forward: Only one copy of the repository on the server — the one CD owns. Checking status means docker ps and docker logs trading_*_dev. The habit of checking out directly on the server was the root cause.
Checklist when external access isn’t working
After three rounds of pain, the diagnostic order is now ingrained. Some things you can only learn from experience. “Can’t reach it from outside” almost always lives in one of these layers:
Container → Host firewall → LAN reachability → Double NAT → Router port forwarding → ISP blocking → DNS → CAA → Certificate
Work down the list and find the first layer that fails — that’s your culprit. One important note: always test external access from your phone on LTE with Wi-Fi off. Testing from inside your home LAN takes a NAT loopback path that bypasses port forwarding entirely, which gives you a false positive.
If you’re using Let’s Encrypt on a home server, check dig +short CAA <domain> and dig +short CAA <parent-domain> before anything else with a new domain. Save yourself the two days I lost.
[Stock App Series] <- Prev: What I Learned Building a Stock App with AI Agents [Home Server Series] <- Prev: Running Multiple Apps on One Home Server See also: Solo Dev Lesson Log · KIS API Regulation Woes