Skip to content

sanand0/auth-pages

Repository files navigation

Secure Static Pages: nginx + Pomerium + Google login

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 serving
  • Pomerium, 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

Important hostname note

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.

1. Run immediately with no configuration

Build and start:

docker compose up --build

Open:

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-C

2. Put your own files in ./sites

Example:

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 up

Open:

http://localhost:9000/demo/

File changes under ./sites are visible without rebuilding the image.

3. Expose it through Cloudflare Tunnel

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 --build

This 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.

4. Add Google OAuth credentials

For secure mode, Pomerium needs a Google OAuth client.

In Google Cloud Console:

  1. Open APIs & Services.
  2. Open OAuth consent screen.
  3. Choose Internal if only your Google Workspace users need access, or External if normal Gmail users may log in.
  4. While testing, add your own Google account as a test user if Google asks for test users.
  5. Open Credentials.
  6. Click Create credentialsOAuth client ID.
  7. Choose Web application.
  8. Add this authorized redirect URI:
https://auth-pages.example.com/oauth2/callback
  1. Copy the Client ID and Client secret.

Now edit .env:

cp .env.example .env

Set:

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.sh

5. Add the authenticate hostname to Cloudflare Tunnel

For 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.

6. Protect a folder

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.

7. Generate Pomerium config and start secure mode

Run:

./scripts/compile-acl.py

This creates:

pomerium/config.yaml

Now start or restart:

docker compose up --build

Because 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.

8. Prove the protection works

  1. Put your real email in sites/private/.access.yaml.
  2. Run:
./scripts/compile-acl.py
docker compose restart pages
  1. Visit:
https://pages.example.com/private/
  1. Confirm it works.
  2. Remove your email/domain from sites/private/.access.yaml.
  3. Run again:
./scripts/compile-acl.py
docker compose restart pages
  1. Visit /private/ again. You should be denied.

9. Force public or secure mode

Auto mode is the default:

SECURE_STATIC_MODE=auto

It means:

  • no pomerium/config.yaml → public nginx-only mode
  • generated pomerium/config.yaml exists → secure Pomerium mode

To force public mode even when Pomerium config exists:

SECURE_STATIC_MODE=public docker compose up

To force secure mode and fail if config is missing:

SECURE_STATIC_MODE=secure docker compose up

10. After config changes

Static file changes under ./sites are visible immediately.

After changing .access.yaml files:

./scripts/compile-acl.py
docker compose restart pages

After changing nginx config:

docker compose restart pages

After changing .env values used by Pomerium:

./scripts/compile-acl.py
docker compose restart pages

No Docker rebuild is required for file, ACL, nginx, Pomerium, or secret changes.

11. File layout

.
├── 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.

12. Security notes

  • .access.yaml and 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, and data/ 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.

13. Useful links

About

Serve static files with auth

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors