Ansible for the Homelab — Bonus: Deploying Your Own Code
The series so far has covered deploying off-the-shelf services - Ghost, Portainer, n8n. Those are straightforward: pull a published image, write a compose file, start the container. Deploying your own code is different. You own the Dockerfile, you manage the build, and your app has its own secrets and configuration that need to get onto the server cleanly.
This post is aimed at developers who want to take an API or service they've written and run it in the homelab using the same Ansible tooling from the rest of the series.
What Your Project Needs
Before Ansible can deploy your code, three things need to be in place in your repository:
1. A Dockerfile. Ansible will build it on the target host, so it needs to produce a working image.
For a Python project using uv:
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev
COPY . .
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
2. A docker-compose.yml. Keep it simple - one service, reads config from environment:
services:
my-api:
build: .
container_name: my-api
restart: unless-stopped
ports:
- "8000:8000"
env_file:
- .env
3. App reads config from environment variables. No hardcoded credentials, no config files committed to the repo. Your app reads os.getenv("DATABASE_URL"), os.getenv("API_KEY"), and so on. Ansible generates the .env file at deploy time.
How Secrets Get to Your App
This is where everything from Part 4 (Vault) comes together. Your app's secrets live in group_vars/vault.yml, encrypted at rest - with a separate configuration block for each environment:
# group_vars/vault.yml (encrypted)
projects:
my_api:
config:
dev:
env_vars:
api_key: "dev-key"
database_url: "postgresql://postgres-dev.lan/mydb"
debug: "true"
prod:
env_vars:
api_key: "prod-key"
database_url: "postgresql://postgres-prod.lan/mydb"
debug: "false"
Dev and prod have completely independent secrets. Different API keys, different database hosts, different feature flags - whatever your app needs to behave differently between environments. There's no risk of a dev deployment accidentally connecting to a production database.
The playbook detects which environment it's deploying to from the hostname - any host with dev in the name uses the dev block, everything else uses prod. It then generates a .env file directly on that server:
# Generated .env on app-dev.lan
API_KEY=dev-key
DATABASE_URL=postgresql://postgres-dev.lan/mydb
DEBUG=true
# Generated .env on app-prod.lan
API_KEY=prod-key
DATABASE_URL=postgresql://postgres-prod.lan/mydb
DEBUG=false
Docker Compose picks up that .env file and passes the values into the container. Your .env files are never committed to git - generated fresh on every deploy, on each host, from the vault.
The Deploy Playbook
A generic deployment playbook handles any project by accepting the project name and target environment as variables:
---
- name: Deploy Project
hosts: "{{ target_env }}"
become: yes
vars_files:
- ../group_vars/vault.yml
tasks:
- name: Clone/update repository
git:
repo: "[email protected]:{your_username}/{{ project }}.git"
dest: "/opt/apps/{{ project }}"
version: "{{ branch | default('main') }}"
accept_hostkey: yes
key_file: /home/ansible/.ssh/id_ed25519
become_user: ansible
- name: Install Python dependencies
shell: cd /opt/apps/{{ project }} && uv sync
become_user: ansible
- name: Generate .env file from vault
template:
src: templates/env.j2
dest: "/opt/apps/{{ project }}/.env"
mode: '0600'
owner: ansible
- name: Build and start containers
shell: |
cd /opt/apps/{{ project }}
docker compose up -d --build
become_user: ansible
Running a Deployment
Deploy to dev first:
ansible-playbook playbooks/deploy-project.yml \
-e "project=my_api target_env=development"
Ansible SSHs into app-dev.lan, clones your repo, generates the dev .env from vault, rebuilds the image, and starts the container. The service is now running internally - reachable on the local network, not exposed to the outside world. Test against it, verify the behavior, confirm it's working as expected.
When you're ready to promote to production:
ansible-playbook playbooks/deploy-project.yml \
-e "project=my_api target_env=production"
Same playbook, different host, different vault config. Ansible generates the prod .env with production credentials and deploys to app-prod.lan.
To deploy a specific branch to dev - useful for testing a feature before merging:
ansible-playbook playbooks/deploy-project.yml \
-e "project=my_api target_env=development branch=feature/new-endpoint"
Exposing Production
Dev stays internal - that's intentional. It's only reachable on the local network, which keeps it off the public internet while you're still working on it.
Once you're satisfied and the code is on app-prod.lan, wire it up through Cloudflare Tunnel using the same two steps from Part 6.
Add an ingress route in roles/cloudflare-tunnel/vars/main.yml:
- hostname: api.example.com
service: http://app-prod.lan:8000
Add a DNS record in roles/cloudflare-dns/vars/main.yml:
- zone: example.com
record: api
type: CNAME
value: "{{ cloudflare_tunnel_id }}.cfargotunnel.com"
proxied: true
Then run:
ansible-playbook playbooks/setup-cloudflare-dns.yml
ansible-playbook playbooks/setup-cloudflare-tunnel.yml
Production is live. Dev remains internal-only and unchanged.
The Full Picture
git push
↓
deploy to development ← app-dev.lan, dev secrets, internal only
↓
test and verify
↓
deploy to production ← app-prod.lan, prod secrets, same command
↓
Cloudflare Tunnel ← prod exposed externally, dev stays private
Two environments, one playbook, independent secrets for each. No SSH sessions, no manual docker build, no copying config files by hand. The same Ansible tooling that runs your homelab infrastructure runs your own code alongside it.