A minimal Docker-deployable static file server that starts with zero auth configuration and later becomes a Google-authenticated, per-folder access-controlled static site.
The image contains only:
nginx, for static file servingPomerium, for Google OIDC login and access control- a small entrypoint that chooses public mode or secure mode
In secure mode, nginx also fronts Pomerium on port 8000. This lets the
container normalize Cloudflare directory-style rewrites such as
/.pomerium/callback/index.html back to Pomerium's reserved
/.pomerium/callback/ endpoint before Pomerium handles the request.
Everything likely to change is mounted from the host:
./sites— static files./sites/**/.access.yaml— folder-local ACL files./nginx— nginx config./pomerium/config.yaml— generated Pomerium config./data— Pomerium runtime state.env— secrets and URLs, only needed for secure mode
With Pomerium Core, do not use the exact same URL as both the protected route and authenticate_service_url.
Use:
pages.example.com -> the static pages
auth-pages.example.com -> Pomerium's authenticate service
Both public hostnames can point through the same Cloudflare Tunnel to the same local service:
http://localhost:9000
This bundle is configured for that pattern. If exact single-host auth such as https://pages.example.com/oauth2/callback is mandatory, Pomerium is not the right fit; use oauth2-proxy + nginx or a custom app instead.
Build and start:
docker compose up --buildOpen:
http://localhost:9000/
You should see the sample site. In this default public mode, nginx serves ./sites directly. The ACL files are still hidden, but not enforced yet.
Try:
http://localhost:9000/public/
http://localhost:9000/private/
Both pages are visible in public mode. This is intentional: it lets the static server work before OAuth configuration exists.
Stop it with:
Ctrl-CExample:
mkdir -p sites/demo
cat > sites/demo/index.html <<'HTML'
<!doctype html>
<h1>Demo</h1>
<p>This is served from ./sites/demo/index.html</p>
HTML
docker compose upOpen:
http://localhost:9000/demo/
File changes under ./sites are visible without rebuilding the image.
Skip this section if you already run cloudflared elsewhere.
In Cloudflare Zero Trust, create or edit a tunnel and add this public hostname:
Hostname: pages.example.com
Service: http://localhost:9000
If you want to run cloudflared from this bundle, add your tunnel token to .env:
cp .env.example .env
# edit .env and set CLOUDFLARED_TOKEN=...Then run:
docker compose -f docker-compose.yml -f docker-compose.cloudflare.yml up --buildThis uses Docker host networking for the cloudflared sidecar, so it is best suited to Linux. If that is inconvenient, run cloudflared directly on the host and keep this app listening on localhost:9000.
At this point, before auth is enabled, this should work:
https://pages.example.com/
DNS and TLS are intentionally not covered here because Cloudflare handles those outside this container.
For secure mode, Pomerium needs a Google OAuth client.
In Google Cloud Console:
- Open APIs & Services.
- Open OAuth consent screen.
- Choose Internal if only your Google Workspace users need access, or External if normal Gmail users may log in.
- While testing, add your own Google account as a test user if Google asks for test users.
- Open Credentials.
- Click Create credentials → OAuth client ID.
- Choose Web application.
- Add this authorized redirect URI:
https://auth-pages.example.com/oauth2/callback
- Copy the Client ID and Client secret.
Now edit .env:
cp .env.example .envSet:
PUBLIC_BASE_URL=https://pages.example.com
AUTHENTICATE_SERVICE_URL=https://auth-pages.example.com
IDP_CLIENT_ID=<your Google OAuth client ID>
IDP_CLIENT_SECRET=<your Google OAuth client secret>Generate Pomerium secrets:
./scripts/init-secrets.shFor secure mode, add a second public hostname to the same tunnel:
Hostname: auth-pages.example.com
Service: http://localhost:9000
So your tunnel has both:
pages.example.com -> http://localhost:9000
auth-pages.example.com -> http://localhost:9000
Both hit the same local port. Pomerium distinguishes them by the Host header.
Edit sites/private/.access.yaml:
public: false
emails:
- your.name@gmail.com
domains:
- yourcompany.com
email_wildcards:
- "*@partner.org"
domain_wildcards:
- "*.school.edu"Supported fields:
public: true|false
emails: # exact email IDs
- alice@example.com
domains: # exact email domains
- example.com
email_wildcards: # supported forms only
- "*@example.com"
- "*@*.example.com"
domain_wildcards: # supported form only
- "*.example.com"Folders without .access.yaml remain public.
Child folders can have their own .access.yaml. More-specific folder rules are generated before broader rules.
Run:
./scripts/compile-acl.pyThis creates:
pomerium/config.yaml
Now start or restart:
docker compose up --buildBecause SECURE_STATIC_MODE=auto, the container detects pomerium/config.yaml and starts in secure mode:
Browser
-> Cloudflare Tunnel
-> nginx edge on localhost:9000
-> Pomerium on 127.0.0.1:8001
-> nginx origin on 127.0.0.1:8080
-> ./sites
Test:
https://pages.example.com/public/
This should remain public.
Then open:
https://pages.example.com/private/
You should be redirected to Google login. After login:
- if your Google email/domain matches
sites/private/.access.yaml, you see the page; - otherwise, Pomerium denies access.
- Put your real email in
sites/private/.access.yaml. - Run:
./scripts/compile-acl.py
docker compose restart pages- Visit:
https://pages.example.com/private/
- Confirm it works.
- Remove your email/domain from
sites/private/.access.yaml. - Run again:
./scripts/compile-acl.py
docker compose restart pages- Visit
/private/again. You should be denied.
Auto mode is the default:
SECURE_STATIC_MODE=autoIt means:
- no
pomerium/config.yaml→ public nginx-only mode - generated
pomerium/config.yamlexists → secure Pomerium mode
To force public mode even when Pomerium config exists:
SECURE_STATIC_MODE=public docker compose upTo force secure mode and fail if config is missing:
SECURE_STATIC_MODE=secure docker compose upStatic file changes under ./sites are visible immediately.
After changing .access.yaml files:
./scripts/compile-acl.py
docker compose restart pagesAfter changing nginx config:
docker compose restart pagesAfter changing .env values used by Pomerium:
./scripts/compile-acl.py
docker compose restart pagesNo Docker rebuild is required for file, ACL, nginx, Pomerium, or secret changes.
.
├── Dockerfile
├── docker-compose.yml
├── docker-compose.cloudflare.yml
├── docker/entrypoint.sh
├── nginx/
│ ├── public.conf # nginx direct public mode, listens on :8000
│ └── secure.conf # nginx edge on :8000 plus origin on 127.0.0.1:8080
├── pomerium/
│ └── config.yaml # generated; ignored by Git
├── scripts/
│ ├── init-secrets.sh
│ └── compile-acl.py
├── sites/
│ ├── index.html
│ ├── public/index.html
│ └── private/
│ ├── .access.yaml
│ └── index.html
├── data/ # Pomerium runtime state
├── .env.example
└── .env # you create; ignored by Git
In secure mode, Pomerium creates data/databroker/ because
scripts/compile-acl.py configures its file-backed databroker at
/var/lib/pomerium/databroker, and Compose bind-mounts that path to ./data.
These Pebble/RocksDB-style files are runtime auth state; stop the container
before deleting data/databroker/, expect sessions/runtime state to reset, and
let Pomerium recreate it on the next secure start. If host root:root files are
inconvenient, use a Docker named volume for /var/lib/pomerium or run the
container as a non-root UID after making nginx and Pomerium runtime paths
writable.
.access.yamland all dotfiles are never served by nginx.- In public mode, ACL files are hidden but not enforced.
- In secure mode, Pomerium enforces access before nginx receives the request.
- Keep
.env,pomerium/config.yaml, anddata/out of Git. - This Compose file binds to
127.0.0.1:9000, so it is intended for Cloudflare Tunnel or local testing, not direct public exposure. - Do not put secrets inside
./sites.
- Pomerium: https://github.com/pomerium/pomerium
- Pomerium policy docs: https://www.pomerium.com/docs/reference/routes/policy
- Pomerium path matching: https://www.pomerium.com/docs/reference/routes/path-matching
- Pomerium identity provider settings: https://www.pomerium.com/docs/reference/identity-provider-settings
- Google OAuth web-server flow: https://developers.google.com/identity/protocols/oauth2/web-server
- Cloudflare Tunnel setup: https://developers.cloudflare.com/tunnel/setup/