Serverless containers are a great way to run a small site: you push an image, you get an HTTPS URL, and you don’t pay for resources when nobody is accessing your application. What you don’t get is caching close to the client, or protection from the cold-start penalty every time a visitor arrives after the serverless container has spun down.

That is exactly what a CDN is for and an excellent choice for this is Varnish CDN which lets you keep all of your infrastructure in Europe. In this post we wire up the whole thing, container, CDN and DNS, in one OpenTofu configuration (main.tf) using the varnish/varnish provider, together with the Scaleway provider.

The focus of this post is the Varnish CDN side, how it is wired to the origin, and how it gets its domain and certificate. For the serverless container part, I will use Scaleway, but keep the explanation to the minimum. You can swap it for your own origin and DNS provider if you like.

By the end, https://test.tf.codito.info/ is served by Varnish CDN, cached at the edge, with an automatically issued TLS certificate, and the origin is a Scaleway serverless container that can scale to zero.

Request flow: the browser resolves the site domain through Scaleway DNS to the Varnish CDN edge, which fetches over HTTPS from the Scaleway serverless container

What you need

  • OpenTofu 1.8 or newer.
  • A Varnish CDN account and an API access token. Sign up here
  • A Scaleway project with an API key, and a DNS zone managed by Scaleway (here tf.codito.info).

Creating a Varnish CDN API token

  1. Log in with the one-time link sent to your email, then select your organization (or create one).
  2. Open the account menu in the top right corner (the black circle with a small avatar) and select Settings.
  3. Select Tokens in the left-hand menu.
  4. Click Create API token. Copy the token right away and keep it somewhere safe, such as a password manager.

The token is what VARNISH_ACCESS_TOKEN is set to below.

Credentials stay out of the code. Both providers read them from the environment:

export VARNISH_ACCESS_TOKEN=...
export SCW_ACCESS_KEY=...
export SCW_SECRET_KEY=...
export SCW_DEFAULT_PROJECT_ID=...

Providers

Because the Varnish provider is on the OpenTofu registry, there is nothing to build or override: declare it and tofu init fetches it.

terraform {
  required_version = ">= 1.8"

  required_providers {
    varnish = {
      source  = "varnish/varnish"
      version = "~> 0.1.1"
    }
    scaleway = {
      source  = "scaleway/scaleway"
      version = "~> 2.87"
    }
  }
}

provider "varnish" {}

provider "scaleway" {
  region = "fr-par"
}

A few variables keep the hostnames in one place:

variable "dns_zone" {
  type    = string
  default = "tf.codito.info"
}

variable "subdomain" {
  type    = string
  default = "test"
}

locals {
  fqdn = "${var.subdomain}.${var.dns_zone}"
  # public_endpoint is a URL; the CDN origin wants a bare hostname.
  origin_host = trimsuffix(trimprefix(scaleway_container.site.public_endpoint, "https://"), "/")
}

Step 1: the origin

To setup the serverless container any image exposing a HTTP endpoint will do. I use originserver, a small Go HTTP server built for exactly this kind of demo: something to put behind a cache and watch how it behaves.

resource "scaleway_container_namespace" "site" {
  name = "codito-site"
}

resource "scaleway_container" "site" {
  name                   = "codito-site"
  namespace_id           = scaleway_container_namespace.site.id
  image                  = "ghcr.io/ullgren/originserver:sha-ae529bc"
  port                   = 8080
  cpu_limit              = 140
  memory_limit_bytes     = 256 * 1024 * 1024
  min_scale              = 0
  max_scale              = 2
  privacy                = "public"
  https_connections_only = true
}

min_scale = 0 is the point of going serverless: no traffic, no cost. The CDN is what makes that acceptable, because cached responses never wake the container.

Step 2: the CDN service

The CDN service ties my desired hostname to the container. Two details matter:

  • The origin is reached over TLS on port 443, since the container is configured to only accept HTTPS (see above).
  • host_header must be the container’s own hostname. Scaleway routes requests by Host; if the CDN forwarded test.tf.codito.info, Scaleway would not know which container you mean.
resource "varnish_cdn_service" "site" {
  domain {
    fqdn = local.fqdn
    tls  = true
  }

  origin {
    name        = "default"
    host        = local.origin_host
    port        = 443
    enable_tls  = true
    is_default  = true
    host_header = local.origin_host
  }

  settings = {
    general = {
      enable_cacheable_ttl_override = true
      cacheable_ttl_override        = "5m"
    }
  }
}

settings maps to the CDN’s configuration wizard. You only set the fields you care about; everything else keeps the service’s current value and never shows up as drift. Here we cache cacheable responses for five minutes regardless of what the origin says. The full list of fields is in the provider documentation.

Step 3: DNS at Scaleway

The CDN needs three records before it will serve the domain:

Record Why
CNAME test → <service>.cdn… Sends traffic to the CDN edge.
TXT with a verification token Proves you own the domain.
CNAME _acme-challenge.test → … Lets the CDN complete the ACME DNS challenge and issue the TLS certificate.

You don’t have to copy any of those values from a UI. The varnish_cdn_domain_dns data source returns the expected values, and tofu feed them straight into scaleway_domain_record:

data "varnish_cdn_domain_dns" "site" {
  service_id = varnish_cdn_service.site.id
  fqdn       = local.fqdn
}

resource "scaleway_domain_record" "cname" {
  dns_zone = var.dns_zone
  name     = var.subdomain
  type     = "CNAME"
  data     = "${data.varnish_cdn_domain_dns.site.expected_cname}."
  ttl      = 60
}

resource "scaleway_domain_record" "verify" {
  dns_zone = var.dns_zone
  name     = ""
  type     = "TXT"
  data     = data.varnish_cdn_domain_dns.site.expected_txt
  ttl      = 60
}

resource "scaleway_domain_record" "acme" {
  dns_zone = var.dns_zone
  name     = "_acme-challenge.${var.subdomain}"
  type     = "CNAME"
  data     = "${data.varnish_cdn_domain_dns.site.expected_acme_cname}."
  ttl      = 60
}

Two things worth knowing:

  • The trailing dot. The data source returns plain hostnames; Scaleway wants CNAME targets fully qualified, so I have to append .. Check the documentation for your DNS provider if that is true or not in your case.
  • Where the TXT goes. A name that carries a CNAME cannot carry any other record, so the TXT cannot live on test.tf.codito.info. The CDN also looks for the token on every parent name, so I put it on the zone apex, tf.codito.info.

How the values flow between the resources

The nice part of doing this in one configuration is that no value is ever copied by hand. Each resource hands what it learned to the next one through references, and OpenTofu turns those references into the order it creates things in:

Dependency flow between the OpenTofu resources: the container feeds the CDN service, whose data source in turn feeds the three DNS records

From the container to the CDN service

Scaleway picks the container’s hostname when it creates it, so I cannot know it in advance. After creation, scaleway_container.site exposes it as public_endpoint, a full URL:

public_endpoint = "https://coditositeeb5ecddb-codito-site.functions.fnc.fr-par.scw.cloud"

The CDN’s origin block wants a bare hostname, so local.origin_host strips the scheme and any trailing slash:

origin_host = trimsuffix(trimprefix(scaleway_container.site.public_endpoint, "https://"), "/")

and the CDN service uses that local twice, as the address to connect to and as the Host header to send:

origin {
  host        = local.origin_host
  host_header = local.origin_host
  # ...
}

Because varnish_cdn_service.site refers to the containers public endpoint (through the local), OpenTofu will not create the CDN service until the container exists and its endpoint is known. In tofu plan you can see this as (known after apply) on the origin:

origin {
    host        = (known after apply)
    host_header = (known after apply)
    port        = 443
    ...
}

After apply, the service’s origin holds the real container hostname:

origin {
    host        = "coditositeeb5ecddb-codito-site.functions.fnc.fr-par.scw.cloud"
    host_header = "coditositeeb5ecddb-codito-site.functions.fnc.fr-par.scw.cloud"
    port        = 443
    ...
}

From the CDN service to the DNS records

The CDN in turn decides the values my DNS needs: which generated hostname to point the CNAME at, which token proves I own the domain, and where to delegate the ACME challenge. None of that exists until the service is created.

The varnish_cdn_domain_dns data source asks the CDN for exactly those values. It takes the service’s id and my service domain :

data "varnish_cdn_domain_dns" "site" {
  service_id = varnish_cdn_service.site.id
  fqdn       = local.fqdn
}

Since service_id is only known once the service exists, OpenTofu postpones reading the data source until then, and says so in the plan:

# data.varnish_cdn_domain_dns.site will be read during apply
# (config refers to values not yet known)

When it is read, it returns the values the CDN expects to find in DNS:

Data source attribute Value in our run Used by
expected_cname bountiful-street.cust.varnish-cdn.net scaleway_domain_record.cname
expected_txt _varnish-cdn-verify=… scaleway_domain_record.verify
expected_acme_cname _acme-challenge.cust.varnish-cdn.net scaleway_domain_record.acme

The three scaleway_domain_record resources reference those attributes in their data field, so they are created last, and in parallel since they don’t depend on each other.

The resulting order

Put together, tofu apply walks the chain from start to end. From our run:

scaleway_container_namespace.site: Creation complete after 6s
scaleway_container.site: Creation complete after 16s
varnish_cdn_service.site: Creation complete after 10s [id=2003485451]
data.varnish_cdn_domain_dns.site: Read complete after 2s
scaleway_domain_record.acme: Creation complete after 0s
scaleway_domain_record.cname: Creation complete after 0s
scaleway_domain_record.verify: Creation complete after 0s

I never have to explicitly define what depends on what: the references alone tell OpenTofu what has to happen first. The same graph, walked backwards, gives the teardown order.

Outputs

Finally, some outputs to see what was created.

output "url" {
  value = "https://${local.fqdn}/"
}

output "origin" {
  value = "https://${local.origin_host}/"
}

output "cdn_cname" {
  value = data.varnish_cdn_domain_dns.site.expected_cname
}

Run it

tofu init
tofu plan
tofu apply

init pulls both providers from registry.opentofu.org and writes .terraform.lock.hcl. Commit that file so everyone gets the same provider builds.

plan will show you what will be applied so that you can validate it before applying.

apply creates six resources and ends with the outputs:

Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

Outputs:

cdn_cname = "bountiful-street.cust.varnish-cdn.net"
origin = "https://coditositeeb5ecddb-codito-site.functions.fnc.fr-par.scw.cloud/"
url = "https://test.tf.codito.info/"

Those are also the three ways to reach the same container: directly at Scaleway (origin), through the CDN on its generated hostname (cdn_cname), and through the CDN on our own domain (url). Let’s try all three, using originserver’s /time page, which waits one second before it answers.

Straight to the container

$ curl -I https://coditositeeb5ecddb-codito-site.functions.fnc.fr-par.scw.cloud/time
HTTP/2 200
content-length: 475
content-type: text/html; charset=utf-8
date: Wed, 21 Oct 2026 20:54:37 GMT
x-envoy-upstream-service-time: 1009
server: envoy

$ curl -I https://coditositeeb5ecddb-codito-site.functions.fnc.fr-par.scw.cloud/time
HTTP/2 200
content-length: 475
content-type: text/html; charset=utf-8
date: Wed, 21 Oct 2026 20:54:41 GMT
x-envoy-upstream-service-time: 1006
server: envoy

server: envoy and x-envoy-upstream-service-time come from the Envoy proxy Scaleway runs in front of every serverless container. The latter is how long the container took to answer, in milliseconds: our one-second delay, every single time. Nothing here is cached.

Through the CDN

The CDN hostname works as soon as the service is deployed, even before your own DNS records have propagated, so it is handy for a first test:

$ curl -I https://bountiful-street.cust.varnish-cdn.net/time
HTTP/2 200
content-length: 475
content-type: text/html; charset=utf-8
date: Wed, 21 Oct 2026 20:52:43 GMT
x-envoy-upstream-service-time: 1011
server: envoy
x-varnish: 59517410
age: 0
via: 1.1 varnish (Varnish/6.0)
strict-transport-security: max-age=31536000; includeSubDomains; preload
accept-ranges: bytes

$ curl -I https://bountiful-street.cust.varnish-cdn.net/time
HTTP/2 200
content-length: 475
content-type: text/html; charset=utf-8
date: Wed, 21 Oct 2026 20:52:43 GMT
x-envoy-upstream-service-time: 1011
server: envoy
x-varnish: 58463684 56565053
age: 7
via: 1.1 varnish (Varnish/6.0)
strict-transport-security: max-age=31536000; includeSubDomains; preload
accept-ranges: bytes

Once DNS is in place (dig +short CNAME test.tf.codito.info should return the cdn_cname), the same thing works on our own domain, with a certificate the CDN issued for it:

$ curl -I https://test.tf.codito.info/
HTTP/2 200
content-length: 622
content-type: text/html; charset=utf-8
date: Wed, 21 Oct 2026 20:55:04 GMT
x-envoy-upstream-service-time: 6
server: envoy
x-varnish: 57871616
age: 0
via: 1.1 varnish (Varnish/6.0)
strict-transport-security: max-age=31536000; includeSubDomains; preload
accept-ranges: bytes

$ curl -I https://test.tf.codito.info/
...
x-varnish: 58537368 51351008
age: 2
...

$ curl -I https://test.tf.codito.info/
...
x-varnish: 59321552 51351008
age: 6
...

Reading the headers

age is the key one. It is the number of seconds the response has been sitting in the cache (RFC 9111).

  • age: 0 means the response is fresh from the origin: this request was a cache miss, and the CDN had to ask the container.
  • age: 7 means the CDN answered from a copy it stored seven seconds earlier. The container was never contacted, so this request did not wait a second for /time, and a container scaled to zero would not have been woken up.

The age keeps growing on every hit (2, 6, …) until it reaches the TTL, five minutes with our cacheable_ttl_override. Then the copy expires, the next request is a miss with age: 0 again, and the cycle restarts. Browsers and other caches downstream use age too: they subtract it from the TTL, so a copy never lives longer in total than the origin (or the CDN configuration) allowed.

The other headers back up the story:

  • x-varnish holds the ID of the current request. On a hit it has a second number, the ID of the request that originally stored the object. Notice that both hits on test.tf.codito.info end in 51351008: they were served from the same stored copy.
  • date and x-envoy-upstream-service-time are frozen on a hit. The second CDN request for /time still says 20:52:43 and 1011 ms, because those headers are part of the stored response, not a new answer from the container. Compare that with the direct requests, where both change every time.
  • via: 1.1 varnish marks that the response passed through the CDN; the direct requests to Scaleway don’t have it.

Images make the cache visible in the browser too. Open https://test.tf.codito.info/images/mandelbrot and reload: straight from the container every reload renders a new fractal, through the CDN you get the same picture until the cached copy expires.

If the domain stays unverified, re-run tofu apply (or tofu refresh) and look at the data source: valid_cname, valid_txt, valid_acme_cname and verified tell you which record the CDN does not see yet.

Tear it down

Everything we created is in OpenTofu’s state, so removing it is one command:

tofu destroy

OpenTofu first refreshes the state, reading every resource back from Scaleway and the CDN to make sure it is deleting what actually exists:

scaleway_container_namespace.site: Refreshing state... [id=fr-par/eb5ecddb-...]
scaleway_container.site: Refreshing state... [id=fr-par/d04339fd-...]
varnish_cdn_service.site: Refreshing state... [id=2003485451]
data.varnish_cdn_domain_dns.site: Reading...
data.varnish_cdn_domain_dns.site: Read complete after 2s
scaleway_domain_record.cname: Refreshing state... [id=tf.codito.info/560e9584-...]
scaleway_domain_record.acme: Refreshing state... [id=tf.codito.info/2c0b7a16-...]
scaleway_domain_record.verify: Refreshing state... [id=tf.codito.info/a708b461-...]

It then prints a plan in which every resource is marked for destruction, and asks for confirmation:

Plan: 0 to add, 0 to change, 6 to destroy.

Changes to Outputs:
  - cdn_cname = "bountiful-street.cust.varnish-cdn.net" -> null
  - origin    = "https://coditositeeb5ecddb-codito-site.functions.fnc.fr-par.scw.cloud/" -> null
  - url       = "https://test.tf.codito.info/" -> null

Do you really want to destroy all resources?
  OpenTofu will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes

Read the plan before you type yes. As a bonus, the CDN service’s entry shows the complete effective configuration, not just the two settings we wrote: defaults such as https_redirect = true, hsts = true and enable_stale_if_error = true came from the wizard template. That is the “track the server” behaviour of settings in action.

The interesting part is the order:

scaleway_domain_record.verify: Destroying... [id=tf.codito.info/a708b461-...]
scaleway_domain_record.cname: Destroying... [id=tf.codito.info/560e9584-...]
scaleway_domain_record.acme: Destroying... [id=tf.codito.info/2c0b7a16-...]
scaleway_domain_record.cname: Destruction complete after 1s
scaleway_domain_record.acme: Destruction complete after 1s
scaleway_domain_record.verify: Destruction complete after 1s
varnish_cdn_service.site: Destroying... [id=2003485451]
varnish_cdn_service.site: Destruction complete after 0s
scaleway_container.site: Destroying... [id=fr-par/d04339fd-...]
scaleway_container.site: Destruction complete after 0s
scaleway_container_namespace.site: Destroying... [id=fr-par/eb5ecddb-...]
scaleway_container_namespace.site: Destruction complete after 6s

Destroy complete! Resources: 6 destroyed.

It is apply in reverse. OpenTofu knows the dependencies from the references in the code: the DNS records use values from the CDN service, the CDN service points at the container, and the container lives in the namespace. So it deletes from the outside in:

  1. DNS records first, in parallel since they don’t depend on each other. test.tf.codito.info stops pointing at the CDN before the CDN service disappears. Resolvers may still hold the old CNAME for up to the 60 second TTL, so a few last requests can still reach the CDN.
  2. The CDN service, which also releases the domain and its certificate.
  3. The container, now that nothing sends traffic to it.
  4. The namespace, which must be empty before Scaleway lets you delete it.

Wrapping up

About a hundred lines of HCL give you a custom domain, edge caching, automatic TLS, and an origin, with no values copied by hand between three different consoles. Swap originserver for your own image and point subdomain at something real, and you have a production-shaped setup.

Appendix: the complete main.tf

Everything shown above comes together in one file. Expand it to copy and adapt:

The complete main.tf
terraform {
  required_version = ">= 1.8"

  required_providers {
    varnish = {
      source  = "varnish/varnish"
      version = "~> 0.1.1"
    }
    scaleway = {
      source  = "scaleway/scaleway"
      version = "~> 2.87"
    }
  }
}

# Credentials come from the environment, see setenv.sh.example:
# VARNISH_ACCESS_TOKEN
# SCW_ACCESS_KEY / SCW_SECRET_KEY / SCW_DEFAULT_PROJECT_ID.
provider "varnish" {}

provider "scaleway" {
  region = "fr-par"
}

variable "dns_zone" {
  type        = string
  description = "Scaleway-managed DNS zone the site lives in."
  default     = "tf.codito.info"
}

variable "subdomain" {
  type        = string
  description = "Label inside dns_zone that the CDN serves."
  default     = "test"
}

locals {
  fqdn = "${var.subdomain}.${var.dns_zone}"
  # public_endpoint is a URL; the CDN origin wants a bare hostname.
  origin_host = trimsuffix(trimprefix(scaleway_container.site.public_endpoint, "https://"), "/")
}

# ---------------------------------------------------------------------------
# Origin: a Scaleway serverless container
# ---------------------------------------------------------------------------
resource "scaleway_container_namespace" "site" {
  name = "codito-site"
}

resource "scaleway_container" "site" {
  name                   = "codito-site"
  namespace_id           = scaleway_container_namespace.site.id
  image                  = "ghcr.io/ullgren/originserver:sha-ae529bc"
  port                   = 8080
  cpu_limit              = 140
  memory_limit_bytes     = 256 * 1024 * 1024
  min_scale              = 0
  max_scale              = 2
  privacy                = "public"
  https_connections_only = true
}

# ---------------------------------------------------------------------------
# CDN service in front of the container
# ---------------------------------------------------------------------------
resource "varnish_cdn_service" "site" {
  domain {
    fqdn = local.fqdn
    tls  = true
  }

  origin {
    name       = "default"
    host       = local.origin_host
    port       = 443
    enable_tls = true
    is_default = true
    # Scaleway routes on Host, so it must be the container's own domain.
    host_header = local.origin_host
  }

  settings = {
    general = {
      enable_cacheable_ttl_override = true
      cacheable_ttl_override        = "5m"
    }
  }
}

data "varnish_cdn_domain_dns" "site" {
  service_id = varnish_cdn_service.site.id
  fqdn       = local.fqdn
}

# ---------------------------------------------------------------------------
# DNS in Scaleway
# ---------------------------------------------------------------------------
resource "scaleway_domain_record" "cname" {
  dns_zone = var.dns_zone
  name     = var.subdomain
  type     = "CNAME"
  data     = "${data.varnish_cdn_domain_dns.site.expected_cname}."
  ttl      = 60
}

# Ownership TXT sits on the zone apex: a CNAME label cannot carry other
# records, and the CDN also accepts the token on any parent name.
resource "scaleway_domain_record" "verify" {
  dns_zone = var.dns_zone
  name     = ""
  type     = "TXT"
  data     = data.varnish_cdn_domain_dns.site.expected_txt
  ttl      = 60
}

resource "scaleway_domain_record" "acme" {
  dns_zone = var.dns_zone
  name     = "_acme-challenge.${var.subdomain}"
  type     = "CNAME"
  data     = "${data.varnish_cdn_domain_dns.site.expected_acme_cname}."
  ttl      = 60
}

output "url" {
  value = "https://${local.fqdn}/"
}

output "origin" {
  value = "https://${local.origin_host}/"
}

output "cdn_cname" {
  value = data.varnish_cdn_domain_dns.site.expected_cname
}

Pontus Ullgren

Senior Consultant / Integration Architect at Redpill Linpro

Pontus works as an integration architect in the Karlstad office. He enjoys making systems talk to each other and automate himself out of repetitive work, lately with a growing interest in infrastructure as code.

EL9's grubby sorting

So I had this el9 machine that had been sitting quietly waiting for updates for a bit too long. Blinded by courage, I went in and did a

dnf update, rebooted, and lo and behold, it worked without a hitch afterwards. Except just one small one: It did not automatically boot the newest kernel. I tried the standard things. Look at /etc/default/grub, look at the output of various grubby commands. To no avail. It simply would ... [continue reading]

From a Luddite to a Vibe-Coder

Published on March 20, 2026