Ansible for the Homelab — Bonus: Deploying Your Own Code

Ansible for the Homelab — Bonus: Deploying Your Own Code
Ansible for the Homelab — The Daily Stomp

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.