Moving a voice agent off a managed platform is never a single click. You need a runtime on your own machine, a portable copy of the agent's logic, and some way to prove the thing still answers correctly once it is there. Below is the path I follow: Lampline as the self-hosted runtime, Dialogflow CX as the agent being migrated.

Plan for 60 to 90 minutes, and expect roughly 30 of those to be spent waiting — DNS propagation and certificate issuance, not clicking. The commands themselves are quick.

One honest caveat before we start: the Lampline image name, tag, default port, environment-variable names and CLI subcommands differ between builds. Wherever a value like that shows up below, treat it as a placeholder and pull the real one from your release notes and lampline --help. The Dialogflow CX part, on the other hand, is standard Google Cloud behaviour and should behave the same for everyone.

Before you start: the checklist

Here is what the deployment assumes:

  • Host — Ubuntu 22.04 LTS or something in that family, starting at 2 vCPU / 4 GB RAM / 20 GB free disk. Voice containers, models and call recordings grow fast, so 20 GB is a floor, not a target.
  • Docker — Engine 24+ plus the Compose v2 plugin (that means docker compose, not the old docker-compose).
  • Ports — 80 and 443 open in the firewall. The app port (8080 in my examples) should only be reachable through the reverse proxy.
  • DNS — an A record such as voice.example.com pointing at the server IP, already propagated.
  • TLS — a certificate for that hostname. Caddy or Nginx + certbot both work; I use Caddy here because it handles issuance on its own.
  • Google Cloud — a project holding your Dialogflow CX agent, and an account whose role includes dialogflow.agents.export. The Dialogflow CX Agent Editor role is one candidate, but confirm against the IAM docs rather than trusting this article.
  • API keys — for the LLM, speech-to-text and text-to-speech providers your particular build supports.

Sanity-check the fundamentals before touching anything:

docker --version && docker compose version df -h /opt dig +short voice.example.com 

If dig comes back empty, stop and fix DNS. Certificate issuance will fail otherwise, and the failure message will not always point at DNS.

Step 1 — Prepare the host

Create the working directory, install Docker if it is missing, and give your user access to the daemon:

sudo apt-get update && sudo apt-get install -y docker.io docker-compose-v2 unzip sudo usermod -aG docker "$USER" # log out and back in afterwards sudo mkdir -p /opt/lampline && sudo chown "$USER" /opt/lampline cd /opt/lampline && mkdir -p data import 

The data directory will hold persistent state; import is a drop zone for CX archives.

Step 2 — Export the Dialogflow CX agent

The export is the only artefact that carries your intents, flows and training phrases across, so get it right first. In the console: Dialogflow CX → your project → your agent → Agent settings (gear icon) → Export agent → Export as ZIP → Download. You end up with something like agent-export.zip in your downloads folder.

If you would rather script it, the REST equivalent is the exportAgent method on the agent resource. API versions and resource paths drift over time, so copy the URL from the current REST reference instead of reciting it from memory:

gcloud auth print-access-token > /tmp/token curl -X POST \ -H "Authorization: Bearer $(cat /tmp/token)" \ -H "Content-Type: application/json" \ "https://dialogflow.googleapis.com/v3/projects/<project-id>/locations/<location>/agents/<agent-id>:exportAgent" \ -d '{"agentUri":"gs://<bucket>/agent-export.zip"}' 

A successful call returns a long-running operation object — {"name": "projects/.../operations/..."} — and the ZIP lands in the bucket you named.

Then move it to the host and list its contents (listing only — you do not need to unpack it for the importer):

scp agent-export.zip <user>@voice.example.com:/opt/lampline/import/ cd /opt/lampline/import && unzip -l agent-export.zip | head -20 

You should see agent.json, a flows/ directory, and intent or test-case entries. An empty listing or a 0-byte archive means the download failed — redo the export.

What you are actually carrying over

Piece of the export Contents What happens to it
agent.json default language, time zone, speech settings copy these into the Lampline agent config so recognition matches
flows/ pages, routes, parameters, conditions flow logic to re-create or verify after import
intents / training phrases example utterances per intent these become the utterance examples for the new agent
webhooks fulfillment URLs pointing at your backend must be replaced with URLs reachable from the Lampline host
test cases regression scenarios re-run them as scripted test calls later

That webhook row is the one that quietly breaks migrations. Any URL pointing at an internal hostname or an old environment will simply not resolve from the new box.

Step 3 — Run Lampline with Docker Compose

Write the environment file first and lock it down:

cd /opt/lampline cat > .env <<'EOF' LAMPLINE_PUBLIC_URL=https://voice.example.com LAMPLINE_ADMIN_KEY=<long-random-string> LLM_API_KEY=<provider-key> STT_API_KEY=<provider-key> TTS_API_KEY=<provider-key> EOF chmod 600 .env 

Then the Compose file. Image name, tag and the port pair below are all build-specific — substitute the ones from your release notes:

services: lampline: image: ghcr.io/<vendor>/lampline:<version> # exact image and tag: check the documentation restart: unless-stopped env_file: .env ports: - "127.0.0.1:8080:8080" # host port : container port — both build-specific volumes: - ./data:/app/data # persistent agents, prompts, call logs - ./import:/import:ro # read-only drop zone for CX exports 

Note the 127.0.0.1: prefix on the port mapping. It keeps the app off the public interface so the only way in is through the proxy.

docker compose pull && docker compose up -d docker compose logs -f --tail=50 lampline 

A healthy start ends with a line announcing the HTTP listener (something like listening on 0.0.0.0:8080), and docker compose ps reports Up (healthy).

Step 4 — Import the archive and confirm registration

Do not guess the import syntax. Every build names its subcommands differently, so ask the binary:

docker compose exec lampline lampline --help docker compose exec lampline lampline agents import --help 

Then run it — the shape below is illustrative:

docker compose exec lampline \ lampline agents import --from /import/agent-export.zip --name support-cx 

The importer prints a summary: how many intents, flows and utterances it created, plus anything it skipped. Read the skipped list. It is usually where webhooks and unsupported constructs show up.

Confirm the agent is registered rather than trusting the summary:

curl -s -H "Authorization: Bearer $LAMPLINE_ADMIN_KEY" \ http://127.0.0.1:8080/api/agents | head 

support-cx should appear in the JSON. A 401 almost always means the admin key in .env and the one in your request are not the same string.

Step 5 — Terminate TLS and verify end to end

With Caddy installed, one command gets you a valid certificate:

sudo caddy reverse-proxy --from voice.example.com --to 127.0.0.1:8080 

https://voice.example.com should now serve the Lampline UI or health endpoint instead of a browser warning.

Now the part people skip. A green container does not mean the agent works: speech-to-text, the LLM, text-to-speech and the imported flow are four separate links in one chain, and each one fails differently. Test in increasing order of realism.

curl -s https://voice.example.com/health curl -s -X POST https://voice.example.com/api/agents/support-cx/test \ -H "Authorization: Bearer $LAMPLINE_ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{"text":"I want to change my delivery address"}' 

Endpoint paths are build-specific, but the test itself is not: send one utterance you know is covered by an imported training phrase. A good response names the matched intent or flow page and returns a generated reply, e.g. {"matched":"change_address", ...}.

Finally, place one real voice call. That is the only check that exercises the audio path — and usually the only one that surfaces a wrong sample rate, a missing STT key, or a webhook that cannot be reached from the new host.

Takeaway

The migration itself is not the hard part. The export is one ZIP, the runtime is one Compose file, and the import is one command. What decides whether this works is the boring preparation: DNS resolving before you ask for a certificate, the app port bound to localhost, the .env file at mode 600, and an end-to-end test that includes an actual phone call rather than a curl request. And read your build's documentation for every placeholder value — the surrounding Google Cloud workflow is stable, but container naming and CLI flags are not.

This article was originally published on AI Release — https://ai-release.org. If you want more self-hosting and AI infrastructure write-ups, the channel is at https://t.me/ai_release1.