Series: Setting up Oracle Cloud VM - V

This is a continuation from: [[004 Connecting via SSH and setting up Traefik]]

✅ Step 5: Setting up Traefik and reverse-proxy

Let’s now set up Traefik inside Docker so it can act as your reverse proxy and entry point for all VMs and services.


🚦 Set Up Traefik in Docker

We’ll configure:

  • Traefik as a Docker container
  • Automatic HTTPS via Let's Encrypt
  • Routing for future containers/VMs
  • Optional dashboard access

📁 Folder Structure

In your VM:

mkdir -p ~/traefik/{conf,certs}
cd ~/traefik
touch conf/traefik.yml
touch conf/dynamic.yml
touch acme.json
chmod 600 acme.json

🛠️ Step 5.1: traefik.yml (Main Config)

Edit ~/traefik/conf/traefik.yml:

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

api:
  dashboard: true

providers:
  docker:
    exposedByDefault: false
  file:
    filename: "/etc/traefik/dynamic.yml"
    watch: true

certificatesResolvers:
  letsencrypt:
    acme:
      email: your-email@example.com
      storage: /acme.json
      httpChallenge:
        entryPoint: web
🔁 Replace your-email@example.com with your real email (needed for Let's Encrypt)

🛠️ Step 5.2: dynamic.yml (Optional Routing/Dashboard Rules)

Edit ~/traefik/conf/dynamic.yml:

http:
  middlewares:
    redirect-to-https:
      redirectScheme:
        scheme: https

  routers:
    traefik:
      rule: "Host(`traefik.yourdomain.com`)"
      entryPoints:
        - websecure
      service: api@internal
      tls:
        certResolver: letsencrypt
Replace traefik.yourdomain.com with a real subdomain (if available) and point its DNS to your VM’s public IP.

🛠️ Step 5.3: docker-compose.yml

Now create ~/traefik/docker-compose.yml:

version: '3'

services:
  traefik:
    image: traefik:v3.0
    container_name: traefik
    restart: always
    command:
      - "--configFile=/etc/traefik/traefik.yml"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./conf/traefik.yml:/etc/traefik/traefik.yml:ro"
      - "./conf/dynamic.yml:/etc/traefik/dynamic.yml:ro"
      - "./acme.json:/acme.json"

🚀 Step 5.4: Launch Traefik

cd ~/traefik
docker compose up -d

Check logs:

docker logs -f traefik

You should see Traefik start and bind to ports 80/443.


🌐 Step 5.5: Access the Dashboard

  1. Visit: http://<your-public-ip> → It should redirect to HTTPS if certs issued
  2. Or use: https://traefik.yourdomain.com (if DNS + certs are configured)
  3. You’ll see Traefik’s dashboard if routing is correct.

🔐 Optional: Add Basic Auth or IP Whitelist for Dashboard

Let me know if you'd like to restrict dashboard access via:

  • HTTP Basic Auth
  • IP allowlist
  • or a domain-only router

✅ Recap

ComponentStatus
Traefik config files✅ Created
Docker Compose✅ Set
Auto TLS✅ Ready (with DNS)
Routing future containers✅ Enabled
Dashboard✅ Routed via subdomain or IP

Next steps:

  • Set up another VM/container (Keycloak, Supabase, etc.)
  • Route it via Traefik
  • Set up shared storage
  • Optional: domain config, HTTPS cert testing, middleware, etc.

Would you like to:

  1. Add a test backend container (like a whoami service)?
  2. Or move on to Keycloak VM setup?

Let’s keep going!

sh-5.1$ find . -iname "kc.sh"
sh-5.1$ /opt/keycloak/bin/kc.sh show-config
Current Mode: production
Current Configuration:
	kc.db-username = kcdbadmin (ENV)
	kc.log-level-org.infinispan.transaction.lookup.JBossStandaloneJTAManagerLookup =  WARN (classpath application.properties)
	kc.log-level-io.quarkus.config =  off (classpath application.properties)
	kc.log-console-output =  default (classpath application.properties)
	kc.db =  postgres (ENV)
	kc.db-password =  ******* (ENV)
	kc.log-level-io.quarkus.hibernate.orm.deployment.HibernateOrmProcessor =  warn (classpath application.properties)
	kc.optimized =  true (Persisted)
	kc.version =  26.3.0 (SysPropConfigSource)
	kc.db-url =  jdbc:postgresql://keycloak-db:5432/keycloak (ENV)
	kc.log-level-org.jboss.resteasy.resteasy_jaxrs.i18n =  WARN (classpath application.properties)
	kc.log-level-io.quarkus.arc.processor.BeanArchives =  off (classpath application.properties)
	kc.log-level-io.quarkus.deployment.steps.ReflectiveHierarchyStep =  error (classpath application.properties)
	kc.proxy =  edge (ENV)
	kc.log-level-org.hibernate.SQL_SLOW =  info (classpath application.properties)
	kc.log-level-io.quarkus.arc.processor.IndexClassLookupUtils =  off (classpath application.properties)
	kc.log-level-org.hibernate.engine.jdbc.spi.SqlExceptionHelper =  off (classpath application.properties)
	kc.run-in-container =  true (ENV)

✅ Fix: Create the external network manually Run this command once to create the traefik_default network:

docker network create traefik_default
Q. Why do we need external network?

Great question — and it’s important to understand why we’re using an external Docker network, especially in your setup with Traefik as a central reverse proxy across services/VMs.


🔁 Why Use an External Network (traefik_default)?

✅ 1. To share a network across multiple Compose projects

You want Traefik in one container to reverse proxy other services like Keycloak, Redis, Supabase, etc. These services may be defined in separate docker-compose.yml files, or even on different VMs in future.

But Docker Compose isolates networks by default per project. So keycloak in project A won’t be visible to traefik in project B — unless you create a shared external network.

Creating a traefik_default external network allows:

  • traefik to see and route to keycloak
  • other services (on this VM or another) to join the same network and be reverse-proxied
  • flexible scaling without tightly coupling all services in one file

✅ 2. Cleaner service decoupling and reusability

With external: true, you can have:

  • One traefik service per host
  • Multiple independent docker-compose.yml files, for:
    • Keycloak
    • Supabase
    • Redis
    • Your custom app

All of them can connect to the shared traefik_default network and be discoverable by Traefik using Docker labels.


✅ 3. Multi-VM & future-proof architecture

In your setup, you want:

  • One Traefik VM with a public IP
  • Other service VMs running containers

By using external named networks, you prepare for a scenario where Docker Swarm or Kubernetes manages overlay networks — which can span hosts.


🔧 Summary

FeatureInternal networkExternal network (traefik_default)
ScopeOnly within one compose projectReusable across multiple compose projects
Traefik reverse proxy access❌ Difficult✅ Easy to route services
Useful for multi-VM setups❌ No✅ Yes
Good for microservices architecture❌ Limited✅ Highly recommended

So yes — using an external network is the best practice for your Traefik-based multi-service architecture.

Q. btw, in our setup until this point, do we need to put any additional resitrctions like cpuCount, RAM, etc on our containers to ensure we never cross oracle free tier limit?

Great follow-up — yes, if you're running multiple containers on Oracle Cloud Free Tier, it’s a very good idea to explicitly restrict CPU and memory per container. Otherwise, Docker may let them consume more than the Free Tier VM's capacity, especially during spikes, which can:

  • Cause OOM (out-of-memory) kills or crashes
  • Slow down the whole system
  • Result in unexpected shutdowns or poor performance

✅ Free Tier Limits (for VM.Standard.A1.Flex):

  • CPU (OCPU): 4 OCPUs
  • RAM: 24 GB RAM
  • Storage: 200 GB block volume (but you’re already managing volumes efficiently)

In your docker-compose.yml, you can set resource limits like so:

    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 256M
⚠️ deploy key works with Docker Swarm, but for regular Docker Compose use, you need to pass resource constraints directly in docker run or with mem_limitcpus, etc. via compose extensions.

So for Compose v2+, add this under each service:

    mem_limit: 512m
    cpus: 0.5

🛠 Example for Keycloak:

  keycloak:
    image: prodkeycloakbuild:26.3
    container_name: keycloak
    restart: unless-stopped
    ...
    mem_limit: 1g
    cpus: 1.0

You can do the same for:

ServiceRAMCPU
Traefik256M0.2
Keycloak DB512M0.5
Keycloak1G1.0
Ghost Blog512M0.5
Code Server1G0.7
Landing Page128M0.1

Just make sure that total allocation stays below ~22GB RAM and 3.5 CPUs, leaving some headroom for the OS and unexpected spikes.


Q. but since we have setup our containers inside a vm, do we need to worry about thiese limits in the first place?

Excellent question — here's the clear answer:

✅ Short Answer: Yes, it's still worth adding resource limits to containers — even inside a VM.

💡 Why? You’re still sharing resources inside the VM:

Your VM has finite CPU and RAM (e.g., 4 OCPUs and 24 GB RAM in Oracle Free Tier). Docker containers by default can use as much as they want from the host, unless told otherwise.

So if you run multiple containers and don’t restrict their resource usage, any of them (e.g., Keycloak, Ghost, or Code Server) might:

Use too much memory or CPU, starving other containers or crashing them

Trigger OOM (Out Of Memory) kills inside the VM

Cause the OS itself (Ubuntu) to swap or freeze

Docker does not automatically throttle containers unless you tell it to.

🧠 Think of it like this:

You're the landlord (the VM), and you let in multiple tenants (containers). If you don't assign rooms (limits), one tenant (say, Keycloak) could take over the kitchen and bathroom too — now no one else can function properly.

🚫 Without limits:

One rogue container can take down everything.

Especially during startup (Ghost or Keycloak migrations, db spikes), containers can spike CPU/RAM.

✅ With limits:

Predictable performance per service

Protection against memory leaks

Easier debugging when something crashes (you’ll know it hit a limit instead of the whole system dying)

⚠️ Free Tier is generous, but not burst-proof.

While Oracle doesn't instantly kill Free Tier instances for high usage, your services can crash or slow down internally if one hogs all VM resources.

📌 Conclusion:

You don’t need to stay under the Free Tier globally (because Oracle enforces that at the VM level), but you should limit your containers inside the VM so they don't fight over resources or cause one another to fail.

Great follow-up — and it touches on a subtle but important Docker behavior.


🔍 Why containers can appear to use more than 1 vCPU on a 1 OCPU VM:

Oracle Cloud Free Tier gives you 1 OCPU, which equals 2 vCPUs (threads). But when you create the instance and run htop or nproc, you might see 2 cores available.

So here’s the breakdown:


🧠 Key Concepts:

1. Docker doesn’t enforce limits by default:

  • If you don’t explicitly set CPU/memory limits, Docker containers can use all available host CPU threads, even if they “appear” to go beyond what your physical CPU can do.
  • So 3 containers each running at 100% CPU might appear to use 300% CPU, but it just means the CPU is saturated, not that you're getting extra CPU power.

2. Docker CPU usage is a proportion:

  • Docker lets you specify CPU shares (relative weights) or hard caps using:
    • --cpus=0.5 → Limits container to half a core
    • --cpu-shares=512 → Relative weight compared to others
  • So if you don’t set these, containers can scale up opportunistically, up to 200% (on 2 threads), giving the illusion of using more than what the system has.

3. Linux CPU accounting:

  • Tools like tophtop, or Docker stats show CPU usage as a percentage per core/thread.
  • So a value like 350% doesn’t mean you're getting 3.5 cores — it means total utilization across multiple containers/threads.

🔒 So, what to do?

Even though the VM enforces the 1 OCPU / 6GB RAM limit globally, your containers can still fight internally unless you add:

  • cpus: 0.5 or cpu_quota/cpu_period
  • mem_limit: 512m or similar

🧪 Bonus: Try it yourself

Run this inside your VM:

docker run --rm -it busybox sh -c 'while :; do :; done'

Then another one. You'll see the CPU getting maxed — and both containers try to eat 100% CPU unless limited.


✅ Summary:

  • Docker containers can oversubscribe CPU inside a VM because the OS just time-shares threads.
  • But you’re not actually exceeding your VM’s physical resources.
  • Still, for stability, you should cap resource usage per container.

In a new folder, create this docker-compose file to run a separate postgres db that keycloak can connect to:

version: "3.9"

services:
  keycloak-db:
    image: postgres:15
    container_name: keycloak-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: kcdbadmin
      POSTGRES_PASSWORD: <your db password>
    volumes:
      - keycloak-db-data:/var/lib/postgresql/data
    networks:
      - keycloak-net

volumes:
  keycloak-db-data:

networks:
  keycloak-net:
    driver: bridge

Now, in this same folder, create a new DockerFile with:

# ===== Stage 1: Builder =====
FROM quay.io/keycloak/keycloak:26.3.0 AS builder

# Enable metrics & health endpoints (optional, but good for production)
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true
ENV KC_DB=postgres

WORKDIR /opt/keycloak

# Run the build step to optimize the server for startup
RUN /opt/keycloak/bin/kc.sh build

# ===== Stage 2: Final Image =====
FROM quay.io/keycloak/keycloak:26.3.0

# Copy built files from builder stage
COPY --from=builder /opt/keycloak/ /opt/keycloak/

# Set Keycloak environment (in production, use secrets or env file for DB password)
ENV KC_DB=postgres
ENV KC_DB_URL=jdbc:postgresql://keycloak-db:5432/keycloak
ENV KC_DB_USERNAME=kcdbadmin
ENV KC_DB_PASSWORD=Phone_527404_dba
ENV KC_HOSTNAME=<insert your custom domain for auth(eg. userauth)>.duckdns.org
ENV KC_PROXY=edge

# Traefik handles TLS, so Keycloak will run in HTTP (not HTTPS) mode
EXPOSE 8080

# Default startup (no need for kc.sh start here, we'll pass it in docker-compose)
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]

Now build the image using:

docker build -t prodkeycloakbuild:26.3 .
Q. when we built this Dockerfile, where's the output? where is the newly generated container?

Great question!

When you run:

docker build -t prodkeycloakbuild:26.3 .

✅ What Happens:

  • Docker builds a new image (not a container) from the Dockerfile in your current directory (.).
  • The final output is a Docker image, tagged as prodkeycloakbuild:26.3.

🔍 Where is this image?

Run:

docker images

You should see an entry like:

REPOSITORY     TAG       IMAGE ID       CREATED         SIZE
mykeycloak     26.3      <some-id>      <a few minutes ago>   <size>

This means: ✅ the build was successful and you now have a reusable, optimized Keycloak image locally.


📦 But why don't I see a container?

Because:

  • docker build creates an image,
  • it does not run a container by itself.

To run a container using this image, you must explicitly do so with:

docker run --name keycloak -p 8080:8080 prodkeycloakbuild:26.3 start --optimized

Or, preferably, using docker-compose.yml where you refer to:

image: prodkeycloakbuild:26.3

🔁 Summary

CommandResult
docker build -t prodkeycloakbuild:26.3 .Creates an optimized Docker image
docker imagesLists available local images
docker run ... or docker compose upActually runs a container from the image

Start optimzed container:

docker compose down # only if container is already up
docker compose up -d # start detached

docker exec -it keycloak /bin/sh # enter inside keycloak container shell
sh-5.1$ /opt/keycloak/bin/kc.sh build # from inside keycloak container shell

🗂️ Where Docker Images Are Stored (Physically)

Docker images are stored on the host filesystem inside a directory managed by the Docker engine.

🔍 Default Location on Linux (Ubuntu):

For most Linux systems (including Ubuntu), Docker stores image layers and metadata under:

/var/lib/docker/

Specifically:

Image data:

/var/lib/docker/overlay2/   ← actual image layers
/var/lib/docker/image/overlay2/   ← metadata

Volumes:

/var/lib/docker/volumes/

Containers (runtime state):

/var/lib/docker/containers/

You can explore with:

sudo du -sh /var/lib/docker/*

⚠️ Do not manually modify anything in here — it's all internally managed by Docker and changing files here can corrupt containers/images.

🧠 How Images Work Under the Hood

Each image is made of multiple read-only layers stacked together, stored under /var/lib/docker/overlay2/.

When you run a container, Docker adds a thin writable layer on top of the image to record changes (this is why containers can be ephemeral).

🧹 Bonus: Free Up Space?

To clean up unused images, containers, networks, and volumes:

docker system prune -a

(But be careful — this removes everything unused!)

✅ Optional: Run a diff check inside container (for nerds 😄)

If you’re curious and still inside the container, try:

ls -l /opt/keycloak/conf
cat /opt/keycloak/conf/keycloak.conf

You should see options like:

kc.optimized=true

✅ Final Folder structure setup:

mkdir -p ~/traefik/{conf,certs}
cd ~/traefik
touch conf/traefik.yml
touch conf/dynamic.yml
touch acme.json
chmod 600 acme.json #update permission

✅ Landing Page for domain home page:

  1. Create a folder named traefik (root folder for our project) if it does not exist.
  2. Create index.html file at traefik/landing/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Welcome - <your domain name></title>
  <style>
    body {
      font-family: system-ui, sans-serif;
      background: #0f172a;
      color: #f1f5f9;
      margin: 0;
      padding: 2rem;
      display: flex;
      flex-direction: column;
      align-items: center;
    }
    h1 {
      margin-bottom: 1rem;
    }
    .links {
      display: grid;
      gap: 1rem;
      margin-top: 2rem;
    }
    a {
      background: #1e293b;
      padding: 1rem 2rem;
      border-radius: 0.5rem;
      color: #f8fafc;
      text-decoration: none;
      transition: 0.2s;
    }
    a:hover {
      background: #334155;
    }
    footer {
      margin-top: 3rem;
      font-size: 0.9rem;
      color: #94a3b8;
    }
  </style>
</head>
<body>
  <h1>Welcome to <your domain name>.duckdns.org</h1>
  <div class="links">
    <a href="https://<your domain name>.duckdns.org" target="_blank">Traefik Dashboard</a>
    <a href="https://<your custom auth domain>.duckdns.org" target="_blank">Keycloak Admin</a>
    <a href="https://blog.<your domain name>.duckdns.org" target="_blank">Personal Blog</a>
    <a href="https://code.<your domain name>.duckdns.org" target="_blank">Code Server</a>
  </div>
  <footer>© All rights reserved.</footer>
</body>
</html>

✅ Below is the final dynamic.yml, traefik.yml, docker-compose.yml:

  • 🛡️ Traefik v3 (with Let's Encrypt + Basic Auth on dashboard)
  • 🧠 Your optimized Keycloak image
  • 🐘 PostgreSQL
  • ✅ Traefik routing for Keycloak at https://.duckdns.org
  • 🔐 Basic Auth enabled on https://.duckdns.org

⚠️ Replace all yourpassword, yourhashedpassword, and admin with secure values!


✅ Add Basic Auth to Traefik Dashboard

🔐 Step 1: Generate a Username + Password Hash

On your local machine or the VM, run:

sudo apt install apache2-utils -y  # if not already installed
htpasswd -nb yourusername yourpassword
Replace yourusername and yourpassword with what you want.

You'll get something like:

yourusername:$apr1$0qvH....$DY4RQ7zIYQhQ9x..kOpOa1

Copy the whole output.


📁 Final dynamic.yml with Basic Auth

Edit/Create your ~/traefik/conf/dynamic.yml file with this:

http:
  middlewares:
    redirect-to-https:
      redirectScheme:
        scheme: https
    auth:
      basicAuth:
        users:
         - "<hash generated above>" 

  routers:
    redirect:
      rule: "HostRegexp(`{host:.+}`)"
      entryPoints:
        - web
      middlewares:
        - redirect-to-https
      service: noop

    traefik:
      rule: "Host(`traefik.<insert your custom domain name>.duckdns.org`)"
      entryPoints:
        - websecure
      middlewares:
        - auth
      service: api@internal
      tls:
        certResolver: letsencrypt

  services:
    noop:
      loadBalancer:
        servers:
          - url: "http://localhost"


Make sure to paste the actual hashed line from step above.


📁 Final traefik.yml with Basic Auth

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

api:
  dashboard: true

providers:
  docker:
    exposedByDefault: false
  file:
    filename: "/etc/traefik/dynamic.yml"
    watch: true

certificatesResolvers:
  letsencrypt:
    acme:
      email: <insert your custom domain name>@gmail.com
      storage: /acme.json
      dnsChallenge:
        provider: duckdns
        delayBeforeCheck: 90

📁 Final docker-compose.yml with Basic Auth

services:
  traefik:
    image: traefik:v3.0
    container_name: traefik
    restart: always
    mem_limit: 256m
    cpus: 0.2
    command:
      - "--configFile=/etc/traefik/traefik.yml"
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./conf/traefik.yml:/etc/traefik/traefik.yml:ro"
      - "./conf/dynamic.yml:/etc/traefik/dynamic.yml:ro"
      - "./acme.json:/acme.json"
    environment:
      - DUCKDNS_TOKEN=b6aa0ab4-7e2c-43ac-926e-3574907a8da7
    networks:
      - traefik_default

  keycloak-db:
    image: postgres:15
    container_name: keycloak-db
    restart: unless-stopped
    mem_limit: 512m
    cpus: 0.5
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: kcdbadmin
      POSTGRES_PASSWORD: <your keycloak password>
    volumes:
      - keycloak-db-data:/var/lib/postgresql/data
    networks:
      - keycloak-net

  keycloak:
    image: prodkeycloakbuild:26.3
    container_name: keycloak
    restart: unless-stopped
    mem_limit: 1g
    cpus: 1.0
    command: start --optimized --http-enabled=true --https-port=0 --proxy-headers=xforwarded
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
      KC_DB_USERNAME: kcdbadmin
      KC_DB_PASSWORD: <your db password>
      KC_HOSTNAME: <your custom auth domain>.duckdns.org
      KC_PROXY: edge
      KC_HEALTH_ENABLED: true
      KC_METRICS_ENABLED: true
      KC_LOG_LEVEL: info
    depends_on:
      - keycloak-db
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.keycloak.rule=Host(`<your custom auth domain>.duckdns.org`)"
      - "traefik.http.routers.keycloak.entrypoints=websecure"
      - "traefik.http.routers.keycloak.tls.certresolver=letsencrypt"
      - "traefik.http.services.keycloak.loadbalancer.server.port=8080"
    networks:
      - keycloak-net
      - traefik_default

  landing:
    image: nginx:alpine
    container_name: landing
    restart: always
    mem_limit: 128m
    cpus: 0.1
    volumes:
      - ./landing:/usr/share/nginx/html:ro
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.landing.rule=Host(`<insert your custom domain name>.duckdns.org`)"
      - "traefik.http.routers.landing.entrypoints=websecure"
      - "traefik.http.routers.landing.tls.certresolver=letsencrypt"
    networks:
      - traefik_default

  code-server:
    image: linuxserver/code-server:latest
    container_name: code-server
    restart: always
    mem_limit: 1g
    cpus: 0.7
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Asia/Kathmandu
      - PASSWORD=<your code server password>
    volumes:
      - ./code-server-config:/config
      - ./code-workspace:/home/coder/project
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.codeserver.rule=Host(`code.<insert your custom domain name>.duckdns.org`)"
      - "traefik.http.routers.codeserver.entrypoints=websecure"
      - "traefik.http.routers.codeserver.tls.certresolver=letsencrypt"
      - "traefik.http.services.codeserver.loadbalancer.server.port=8443"
    networks:
      - traefik_default

  ghost:
    image: ghost:5-alpine
    container_name: ghost
    restart: always
    mem_limit: 512m
    cpus: 0.5
    environment:
      url: https://blog.<insert your custom domain name>.duckdns.org
      database__client: sqlite3
      database__connection__filename: /var/lib/ghost/content/data/ghost.db
    volumes:
      - ./ghost/content:/var/lib/ghost/content
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.ghost.rule=Host(`blog.<insert your custom domain name>.duckdns.org`)"
      - "traefik.http.routers.ghost.entrypoints=websecure"
      - "traefik.http.routers.ghost.tls.certresolver=letsencrypt"
      - "traefik.http.services.ghost.loadbalancer.server.port=2368"
    networks:
      - keycloak-net
      - traefik_default

volumes:
  keycloak-db-data:

networks:
  keycloak-net:
  traefik_default:
    external: true

🔄 Clear volumes, delete all previous docker artifacts, and restart everything

Now reload Traefik to apply the updated config:

cd ~/traefik
docker compose down
docker compose up -d

🧪 Step 4: Test It

Go to:

https://<insert your custom domain name>.duckdns.org

✅ You should now see a username/password prompt before the dashboard loads.

This brings us to the end of this series. You have a fully working personal cloud-home-lab accessing from anywhere on the internet.

Hopefully you found this series useful, and learnt something in the process!!!