Deploy and configure the Content Cache charm

The content-cache charm caches static content from a backend and serves it back to clients. On its own, each backend it caches is reachable only through a dynamically allocated TCP port (starting at 30000) on the units it is deployed to. content-cache can terminate TLS for that single hostname, but it has no hostname-based (SNI) routing across multiple certificates, and no ingress-level protections.

This tutorial shows you how to front content-cache with the ingress-configurator and haproxy charms. Together, they let clients reach your cached content through a normal hostname over HTTPS, and unlock features such as SNI-based hostname routing, retries, and DDoS protections that content-cache does not provide by itself.

Everything in this tutorial runs on a local LXD cloud, so you can follow along without access to any Canonical-internal infrastructure.

What you’ll do

  • Deploy content-cache and a minimal origin server.

  • Deploy ingress-configurator and integrate it with content-cache to replace manual per-relation backend configuration.

  • Deploy haproxy and integrate it with ingress-configurator to add hostname-based routing.

  • Add TLS termination at the ingress with a self-signed certificate.

What you’ll need

You will need a workstation, e.g., a laptop, with AMD64 architecture. Your workstation should have at least 4 CPU cores, 8 GB of RAM, and 50 GB of disk space.

Tip

You can use Multipass to create an isolated environment by running:

multipass launch 24.04 --name charm-tutorial-vm --cpus 4 --memory 8G --disk 50G

Then log in to the VM with:

multipass shell charm-tutorial-vm

This tutorial requires the following software to be installed on your workstation (either locally or in the Multipass VM):

  • Juju 3

  • LXD

  • jq

Use Concierge to set up Juju and jq:

sudo snap install --classic concierge
sudo concierge prepare -p machine

This first command installs Concierge, and the second command uses Concierge to install and configure Juju and jq.

For this tutorial, Juju must be bootstrapped to a LXD controller. Concierge should complete this step for you, and you can verify by checking for msg="Bootstrapped Juju" provider=lxd in the terminal output and by running juju controllers.

If Concierge did not perform the bootstrap, run:

juju bootstrap localhost tutorial-controller

Set up the environment

To manage resources effectively and to separate this tutorial’s workload from your usual work, create a new model in the LXD controller using the following command:

juju add-model content-cache-tutorial

Deploy Content Cache and a backend

Deploy the Content Cache charm from the 1/edge channel:

juju deploy content-cache --channel 1/edge

content-cache needs a backend to cache. Deploy a plain Ubuntu machine and install nginx, which starts automatically and serves a default page, to stand in for a real origin:

juju deploy ubuntu --base ubuntu@24.04 origin
juju exec --unit origin/0 -- "sudo apt-get install -y nginx && echo '<h1>Hello from origin</h1>' | sudo tee /var/www/html/index.html"

Wait for the origin application to settle into active/idle:

juju status --watch 5s

Save the origin unit’s IP address to an environment variable so you can reuse it in later commands:

export ORIGIN_IP=$(juju status --format json | jq -r '.applications.origin.units."origin/0"."public-address"')

Deploy and integrate Ingress configurator

ingress-configurator translates a set of configuration options into the cache-config relation data that content-cache consumes, replacing the need to configure the relation by hand. Deploy it from the latest/edge channel:

juju deploy ingress-configurator --channel latest/edge

Point it at the origin server you deployed in the previous step:

juju config ingress-configurator \
  backend-addresses=$ORIGIN_IP \
  backend-ports=80 \
  backend-protocol=http

By default, content-cache only caches a response if the backend’s own Cache-Control or Expires headers say it’s cacheable, and our test origin doesn’t send either. So you can see caching in action later in the tutorial, tell content-cache to cache successful responses for an hour:

juju config ingress-configurator cache-proxy-cache-valid="200 1h"

Integrate ingress-configurator with content-cache over the cache-config endpoint:

juju integrate content-cache:cache-config ingress-configurator:cache-config

At this point both charms remain blocked: ingress-configurator won’t publish backend configuration to content-cache over cache-config until it also has a route relation, which you’ll add next by deploying haproxy.

Deploy HAProxy and add hostname-based routing

So far, clients would reach the cache through content-cache’s dynamically allocated TCP port, with no hostname-based routing and no protection beyond what content-cache itself provides. Adding haproxy in front of ingress-configurator gives clients a normal HTTPS hostname to connect to, and enables protocol- and DDoS-level protections by default (connections with invalid, empty, or missing host headers are dropped, and connection/ keep-alive timeouts are enforced), without any extra configuration.

Deploy haproxy from the 2.8/stable channel:

juju deploy haproxy --channel 2.8/stable

Integrate it with ingress-configurator over the haproxy-route endpoint:

juju integrate ingress-configurator:haproxy-route haproxy:haproxy-route

Let’s give our deployment a hostname:

juju config ingress-configurator hostname=content-cache.local

ingress-configurator forwards this hostname to haproxy, which uses it both for request routing and as the certificate common name once TLS is enabled.

haproxy-route requires HTTPS by default, and haproxy will not become active until it has a TLS certificate, which you’ll add in the “Terminate TLS at the ingress” section below. Watch the deployment settle: content-cache and ingress-configurator reach active/idle once ingress-configurator publishes the backend configuration, while haproxy remains blocked — this is expected at this point:

juju status --watch 5s

Confirm the caching

Save the content-cache unit’s IP address to an environment variable and curl it directly on the port allocated for this relation (starting at 30000):

export CONTENT_CACHE_IP=$(juju status --format json | jq -r '.applications."content-cache".units."content-cache/0"."public-address"')
curl http://$CONTENT_CACHE_IP:30000

You should see Hello from origin.

To confirm content-cache is actually caching the response rather than just forwarding it, send the same request twice and inspect the cache log on the unit. content-cache logs a cache_status field for every request, distinguishing a first-time MISS from a subsequent HIT:

curl http://$CONTENT_CACHE_IP:30000 -o /dev/null -s
curl http://$CONTENT_CACHE_IP:30000 -o /dev/null -s
juju ssh content-cache/0 -- sudo tail -3 /var/log/nginx/content-cache_0/30000.cache.log

The first request populates the cache ("cache_status": "MISS"), and the second is served straight from it ("cache_status": "HIT"), without origin being contacted again.

Terminate TLS at the ingress

haproxy needs a TLS certificate before it can leave blocked and start routing traffic. For this tutorial, deploy self-signed-certificates and integrate it with haproxy:

juju deploy self-signed-certificates --channel 1/stable
juju integrate haproxy:certificates self-signed-certificates:certificates

Warning

self-signed-certificates is only suitable for local testing. In production, use a real certificate authority such as Let’s Encrypt via the lego charm.

Once the relation settles, haproxy requests and receives a certificate for content-cache.local (the hostname you configured earlier). Wait for haproxy and self-signed-certificates to both reach active/idle before continuing:

juju status --watch 5s

You should see all five applications active/idle:

user@host:~$
juju status
Model                   Controller     Cloud/Region         Version  SLA          Timestamp
content-cache-tutorial  concierge-lxd  localhost/localhost  3.6.28   unsupported  08:52:29-04:00

App                       Version  Status  Scale  Charm                     Channel        Rev  Exposed  Message
content-cache                      active      1  content-cache             1/edge         534  no
haproxy                            active      1  haproxy                   2.8/stable     557  no       1/1 valid relations
ingress-configurator               active      1  ingress-configurator      latest/edge    107  no       Ready
origin                    24.04    active      1  ubuntu                    latest/stable   79  no
self-signed-certificates           active      1  self-signed-certificates  1/stable       586  no

Unit                         Workload  Agent  Machine  Public address  Ports       Message
content-cache/0*             active    idle   0        10.48.188.3     30000/tcp
haproxy/0*                   active    idle   3        10.48.188.161   80,443/tcp  1/1 valid relations
ingress-configurator/0*      active    idle   2        10.48.188.138               Ready
origin/0*                    active    idle   1        10.48.188.164
self-signed-certificates/0*  active    idle   4        10.48.188.150

Machine  State    Address        Inst id        Base          AZ                 Message
0        started  10.48.188.3    juju-2724a6-0  ubuntu@24.04  charm-tutorial-vm  Running
1        started  10.48.188.164  juju-2724a6-1  ubuntu@24.04  charm-tutorial-vm  Running
2        started  10.48.188.138  juju-2724a6-2  ubuntu@24.04  charm-tutorial-vm  Running
3        started  10.48.188.161  juju-2724a6-3  ubuntu@24.04  charm-tutorial-vm  Running
4        started  10.48.188.150  juju-2724a6-4  ubuntu@24.04  charm-tutorial-vm  Running

Fetch the issued certificate’s CA so you can verify it with curl:

juju run haproxy/0 get-certificate hostname=content-cache.local --format=json \
  | jq -r '.[].results.ca' > ca.pem

Find the haproxy unit’s IP address, save it to an environment variable, and test the whole path end to end, resolving the hostname to that address:

export HAPROXY_IP=$(juju status --format json | jq -r '.applications.haproxy.units."haproxy/0"."public-address"')
curl --resolve content-cache.local:443:$HAPROXY_IP --cacert ca.pem https://content-cache.local/

You should see Hello from origin again — but this time served over HTTPS, on the standard port, addressed by a hostname you chose, with no need to know or track the port that content-cache allocated internally.

Compared to integrating ingress-configurator directly to content-cache, adding haproxy in front unlocks:

  • Hostname and path-based routing: reach the cache by name over the standard HTTPS port.

  • TLS termination: haproxy presents a real client-facing certificate, obtained automatically through the certificates relation.

Next steps

Now that you have a working deployment with hostname-based routing, DDoS protections, and TLS termination, you can:

  • Explore the ingress-configurator and haproxy configuration references for additional controls such as health check tuning and retries.

  • Use a real certificate authority in production by integrating haproxy with the lego charm instead of self-signed-certificates.

  • Read the content-cache how-to guides for operations such as enabling COS observability or connecting to HTTPS backends.

Clean up

Remove the applications you deployed in this tutorial:

juju remove-application content-cache ingress-configurator haproxy self-signed-certificates origin

If you created a Multipass VM for this tutorial, you can also remove it:

multipass delete --purge charm-tutorial-vm