A hardened self-hosted container platform — SSO-protected applications behind
a reverse proxy, network-segmented services, and no inbound ports open.
Enterprise Security (Cloud & IoT) — CA2 · BSc (Hons) Cloud Computing, TU Dublin. Cathal Flood · C22312211
A private cloud hosting environment built on a single Ubuntu Server 24.04 VM, designed so that every application published from it is authenticated, every backend service is unreachable from the internet, and the host exposes no inbound ports at all.
The brief was to host applications securely. The interesting part is not that it runs — it's the set of controls that make it defensible, and being able to demonstrate each one.
Internet
│
▼
┌─────────────────────┐
│ Cloudflare Tunnel │ outbound-only connector,
│ (cloudflared) │ no inbound ports on the host
└──────────┬──────────┘
▼
┌─────────────────────┐
│ Traefik │ single ingress point,
│ reverse proxy │ host-based routing
└──────────┬──────────┘
│
┌─────────────┴──────────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ forward-auth │◄──────│ protected routes │
│ (OIDC middleware) │ │ every request │
└─────────┬──────────┘ └────────────────────┘
│ OIDC authorisation code flow
▼
┌────────────────────┐
│ Keycloak │ realm: securelab
│ identity server │ roles: app-user, app-admin
└─────────┬──────────┘
│
╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
backend network — internal: true, no route to the internet
│
┌──────┴──────┬───────────────┬──────────────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌───────────┐ ┌──────────────┐
│ Postgres│ │Prometheus │ │ Grafana │ │node-exporter │
└─────────┘ └───────────┘ └───────────┘ └──────────────┘
Two Docker networks do the heavy lifting. frontend is a normal bridge carrying
proxy-facing traffic. backend is declared internal: true, so Postgres, Prometheus and
node-exporter have no route off the host — they can be reached by the services that
need them and by nothing else.
| Control | Implementation |
|---|---|
| No inbound exposure | Cloudflare Tunnel makes an outbound-only connection; the VM's firewall needs no port-forward and the host has no public IP |
| Single ingress | Traefik is the only route to any application; exposedByDefault: false means a container is unreachable until it explicitly opts in with a label |
| SSO on every protected route | traefik-forward-auth runs as a Traefik middleware — requests without a valid session are bounced to Keycloak before reaching the app |
| Centralised identity | Keycloak realm securelab with the OIDC authorisation code flow; the application holds no credentials of its own |
| Role-based access | app-user and app-admin roles assigned in Keycloak, carried in the token |
| Network segmentation | backend network is internal: true; the database is not reachable from the proxy tier |
| TLS floor | minVersion: VersionTLS12 in the dynamic Traefik config |
| Image scanning | Trivy against the Keycloak and Traefik images |
| Runtime testing | OWASP ZAP baseline scan against the published application |
| Backup and restore | Scripted pg_dump plus tarball of the Grafana and Prometheus volumes |
| Observability | Prometheus scraping node-exporter and Traefik; Grafana for dashboards; Dozzle for container logs |
scripts/verify.sh checks that all nine containers are running, then
actively tries to reach Postgres from inside the Traefik container:
docker exec traefik wget -q --timeout=2 http://postgres:5432A failed connection is the pass condition. A control you haven't tried to break is an assumption, not a control.
Run against the published application. Full output in
security/zap/, including the HTML report.
| Passed | 56 rules |
| Warnings | 11 |
| Failures | 0 |
The warnings are mostly transport-header issues, and they are consistent with the
architecture rather than accidental: TLS terminates at the Cloudflare edge and the tunnel
delivers plain HTTP to the origin, so the origin never sets Strict-Transport-Security and
INSECURE_COOKIE=true is set on forward-auth for the same reason. The remainder — no CSP,
no Permissions-Policy, SameSite=None on session cookies — are genuine gaps, listed
below.
Results in security/trivy/.
| Image | Critical | High |
|---|---|---|
quay.io/keycloak/keycloak:24.0 |
2 | 4 |
traefik:v2.11 |
1 | 2 |
Both images are pinned to a specific minor version, which is what makes these numbers
meaningful and actionable — a :latest tag would have made the scan a snapshot of nothing
in particular.
Where this environment is not production-ready. Being able to name these is the point of a security exercise.
- The Traefik dashboard is unauthenticated.
api.insecure: trueserves the dashboard without credentials. Abasic-authmiddleware is defined intraefik/dynamic/middlewares.ymlbut is not attached to the dashboard router, so it currently protects nothing. - Keycloak runs in development mode.
start-devwithKC_HOSTNAME_STRICT: false. A production deployment needsstart, a strict hostname, and its own TLS material. - Session cookies are not marked Secure (
INSECURE_COOKIE=true), because internal routing between the tunnel and Traefik is HTTP. - No CSP, HSTS or Permissions-Policy on responses — ZAP flags all three.
- No anti-CSRF tokens on the protected application (
whoamiis a diagnostic endpoint, not a real app, so this is inherited from the test target). - Docker socket is mounted read-only into Dozzle and Traefik. Read-only limits the blast radius but socket access is still effectively host-level privilege.
- Single host, no high availability — everything shares one VM and one failure domain.
Requires Docker and Docker Compose on Ubuntu Server 24.04, and a Cloudflare Tunnel token.
git clone https://github.com/floodcathal/secure-private-cloud.git
cd secure-private-cloud
cp .env.example .env # fill in every value
docker compose up -d
./scripts/verify.shKeycloak then needs a realm called securelab, a confidential client whoami-app, and the
app-user / app-admin roles. Put the client secret into .env as OIDC_CLIENT_SECRET.
| Service | Route |
|---|---|
| Protected application | app.<your-domain> |
| Keycloak | auth.<your-domain> |
| Grafana | metrics.<your-domain> |
| Dozzle logs | logs.<your-domain> |
| Traefik dashboard | traefik.<your-domain> |
./scripts/backup.sh # pg_dump + Grafana/Prometheus volume tarballs
./scripts/restore.sh 20250427_143000docker-compose.yml the full stack — nine services, two networks
.env.example every variable the compose file reads
traefik/
traefik.yml static config: entrypoints, providers, logging
dynamic/middlewares.yml basic-auth middleware definition
dynamic/tls.yml TLS floor (minVersion TLS 1.2)
prometheus.yml scrape targets
scripts/
verify.sh health + network isolation checks
backup.sh / restore.sh database and volume backup
security/
trivy/ image vulnerability scans
zap/ OWASP ZAP baseline scan and HTML report
docs/README.md setup and operation notes
Credentials come entirely from environment variables — see .env.example.
No .env, ACME account key, certificate or backup archive is tracked in this repository.