Automated VCF 9 Offline Depot architecture diagram showing Traefik reverse proxy and Nginx file server stack
| | | | | |

Automated VCF 9 Offline Depot

This Automated VCF 9 Offline Depot was built to solve a simple problem: VCF 9 changed its deployment model, and I needed a repeatable, hands-off way to spin one up in my lab. The script and Ansible playbook take a fresh Ubuntu VM and turn it into a fully working depot with publicly-trusted SSL certificates from Let’s Encrypt.

The deployment means no manual Docker installs, no messing with certificates, no clicking through dashboards. Just SSH in, run one command, and have a fully working HTTPS file server with automatic Let’s Encrypt certificates via Cloudflare DNS. It basically simplifies and automates the official process documented here. Thanks to Gareth for pushing me to finish this and helping with testing.

If you want to jump in and deploy right away, you can execute the below or go and look at the GitHub repo here

curl -sSL https://raw.githubusercontent.com/jameskilbynet/iac/main/docker/vcf9-offline-depot/deploy.sh -o /tmp/deploy.sh
sudo bash /tmp/deploy.sh

Prerequisites

  • A Cloudflare-managed domain + API key with DNS Zone:DNS:Edit and Zone:Zone:Read permissions
  • Ubuntu 22.04+ with at least 100GB of free disk space. I would recommend 200GB+
  • Internet access from the Ubuntu VM
  • DNS records to be used for the web server and the Traefik dashboard. For this I am using vcf.jameskilby.cloud and traefik.jameskilby.cloud

What it does

  • A single bash script takes a vanilla Ubuntu server and builds the entire stack:
  • Installs GitAnsibleDocker Engine and Docker Compose
  • Deploys Traefik as a reverse proxy with automatic wildcard SSL via Cloudflare DNS challenge
  • Deploys Nginx as a file server with directory browsing enabled
  • Creates a /vcf directory on the host, owned by the deploying user, mounted into the container
  • Protects the web server with HTTP basic authentication
  • Forces all HTTP traffic to HTTPS
  • Exposes the Traefik dashboard on its own subdomain
Vcf Offline Depot 1024X538

The script pulls and executes the below files.

docker/traefik-nginx/
├── deploy.sh              # Bootstrap script — run this
├── playbook.yml           # Ansible playbook — does the work
├── docker-compose.yml     # Root compose file
├── .env.example           # Example environment variables
└── compose/
    ├── traefik.yml        # Traefik reverse proxy
    └── nginx.yml          # Nginx file server

When it’s deployed you can drop the VCF installer files into the /vcf directory and they are passed through to the web server for use in VCF deployments. I do this using FileZilla but any method will work.

What the script does under the hood is

1. Docker Installation — Adds Docker’s official GPG key and APT repository, installs Docker Engine, CLI, containerd, Buildx and the Compose plugin. It detects the system architecture automatically so it works on both amd64 and arm64.

2. Directory Structure — Creates the stack directory at /opt/traefik-nginx with subdirectories for compose files, Traefik config, and Nginx config. Creates /vcf owned by the user who ran sudo so you can write files there without root.

3. Nginx Config — Writes a custom default.conf that enables autoindex for directory browsing with human-readable file sizes and local timestamps.

4. Traefik Static Config — Generates traefik.yml with:

  • HTTP and HTTPS entrypoints
  • Cloudflare DNS challenge for Let’s Encrypt certificates
  • Docker provider for automatic service discovery
  • Dashboard enabled via its own subdomain

5. Environment File — Writes .env (mode 0600) containing the domain, subdomain, Cloudflare token, basic auth hash, and install directory. Docker Compose interpolates these into container labels at runtime.

6. Network & Stack — Creates the traefik Docker network (idempotent), then runs docker compose up -d.

The stack is split across two compose files included from a root docker-compose.yml:

Traefik (compose/traefik.yml):

  • Binds ports 80 and 443
  • Mounts the Docker socket (read-only) for container discovery
  • Global HTTP-to-HTTPS redirect via a catchall router
  • Dashboard exposed at traefik.yourdomain.com

Nginx (compose/nginx.yml):

  • Mounts /vcf from the host as the web root (read-only)
  • Custom nginx config for directory browsing
  • Basic auth middleware via Traefik labels, reading credentials from ${BASICAUTH_USERS} in .env
  • Serves on ${SUBDOMAIN}.${DOMAIN}

Both containers run with no-new-privileges security option and JSON file logging with rotation.

Security

Whenever you’re adding passwords or tokens into a system, care needs to be taken. The script will handle the Cloudflare API Token and password in a secure manner. When the secure variables are entered they are handled with read -rsp this means that they don’t echo to the terminal as they are entered. These secrets are stored temporarily in a file with root only readable permissions. This has an automatic cleanup so that if the script completes successfully or if it crashes or is stopped manually this file is removed using trap 'rm -f "$VARS_FILE"' EXIT INT TERM. Unset is also used to clear plaintext variables from the shell after they have been written to disk.

How to use

SSH into your VM and run

curl -sSL https://raw.githubusercontent.com/jameskilbynet/iac/main/docker/vcf9-offline-depot/deploy.sh -o /tmp/deploy.sh
sudo bash /tmp/deploy.sh

The script will prompt for your domain, subdomain, web-server username and password, and Cloudflare API token. It confirms your input, then handles everything else.

Rundeployment 1024X508

A few minutes later you’ll see:

═══════════════════════════════════════════
  Deployment Complete
═══════════════════════════════════════════

  Nginx:     https://vcf.jameskilby.cloud
  Traefik:   https://traefik.jameskilby.cloud
  Web root:  /vcf
  Stack dir: /opt/vcf9-offline-depot

  DNS: Point *.jameskilby.cloud to this server's IP.
  Certs will be issued automatically via Cloudflare DNS.

Your Automated VCF 9 Offline Depot is now running and accessible over HTTPS.

If you then access the nginx web server at your defined address i.e. https://vcf.jameskilby.cloud

It should prompt you to log in using the credentials you defined at runtime. If it’s all working you should be presented with the following webpage and it should be on a fully trusted SSL certificate.

Workingnginx 1024X752

If you like the Traefik + Cloudflare DNS pattern used here, the same stack powers my homelab AI infrastructure. These posts cover how it all fits together:

Troubleshooting

Most problems fall into one of a handful of categories. Here are the checks I run first when something doesn’t come up clean.

Certificates aren’t being issued

The first thing to do is wait. It can take a minute or more for the process of issuing the certificate to complete. Don’t start troubleshooting immediately.

If, after a minute, Traefik is still serving the default self-signed certificate instead of a Let’s Encrypt one, it’s almost always the Cloudflare API token. Verify the token is valid and scoped correctly:

curl -s "https://api.cloudflare.com/client/v4/user/tokens/verify" \
  -H "Authorization: Bearer <token>"

The token needs Zone:DNS:Edit and Zone:Zone:Read permissions on the zone you’re issuing certificates for. Then check the Traefik logs for ACME errors:

docker logs traefik 2>&1 | grep -i acme

One issue you might hit (which I ran into while documenting things) was hitting Let’s Encrypt’s rate limits. You will see something like this in the Traefik logs if you do

sudo docker logs traefik 2>&1 | grep -i "docker" | tail -10

ERR Unable to obtain ACME certificate for domains error="unable to generate a certificate for the domains [jameskilby.cloud *.jameskilby.cloud]: acme: error: 429 :: POST :: https://acme-v02.api.letsencrypt.org/acme/new-order :: urn:ietf:params:acme:error:rateLimited :: too many certificates (5) already issued for this exact set of identifiers in the last 168h0m0s, retry after 2026-04-11 03:06:50 UTC: see https://letsencrypt.org/docs/rate-limits/#new-certificates-per-exact-set-of-identifiers" ACME CA=https://acme-v02.api.letsencrypt.org/directory acmeCA=https://acme-v02.api.letsencrypt.org/directory domains=["jameskilby.cloud","*.jameskilby.cloud"] providerName=cloudflare.acme routerName=nginx@docker rule=Host(`vcf.jameskilby.cloud`)

If you’re stuck on a staging certificate after fixing the token, truncate acme.json and restart Traefik so it requests a fresh one:

truncate -s 0 /opt/traefik-nginx/traefik/acme.json
docker restart traefik

Docker fails to install

If the playbook fails installing Docker, the VM usually has pending kernel updates waiting for a reboot. Either reboot manually and re-run, or allow the playbook to reboot for you by re-running with -e allow_reboot=true.

Increase Disk Size

The script will fail if you don’t have 100GB of available storage. You will need at least this for the VCF install binaries. If you have presented a single large disk to the VM and used Ubuntu’s auto partitioning it won’t utilise all of the drive. See my blog here on how to expand the disk to its full size

Review the Traefik Dashboard

If you’re still having issues review the Traefik Dashboard https://traefik.domain.com

You should see four routers and six services like the attached screenshots

Traefikrouters 1024X254
Traefikservices 1024X326

Basic auth prompt loops

If the depot keeps prompting for credentials even with the right ones, it’s almost always $ escaping. Docker Compose treats $ as variable interpolation, so every $ in the apr1 hash must be doubled to $$. The deploy script handles this automatically, but if you’re editing .env by hand, double-check the escaping.

Similar Posts