Skip to content

Installation

Quick Start (Fresh Install)

The fastest way to get Milestone running with a self-contained PostgreSQL instance:

# Clone the repository
git clone https://github.com/vincentmakes/milestone-planner.git
cd milestone-planner

# Create environment file
cp .env.example .env
# Edit .env — SESSION_SECRET is REQUIRED (it ships empty). Generate one with:
#   python -c "import secrets; print(secrets.token_hex(32))"

# Start everything (PostgreSQL + app)
docker compose -f docker-compose.fresh.yml up -d

SESSION_SECRET is required

docker-compose.fresh.yml reads SESSION_SECRET from .env and refuses to start when it is missing (Set SESSION_SECRET in .env …). The application itself also refuses to start with DEBUG=false when SESSION_SECRET is empty or one of the published example values (the old built-in default or the placeholders from earlier .env.example / compose files). Pick a fresh random value per installation — it signs sessions and SSO sign-in state, and in single-tenant mode it is also the source of the key that encrypts the stored SSO client secret (see Organization & SSO).

The fresh install will:

  1. Start a PostgreSQL 15 container
  2. Wait for database readiness
  3. Auto-create databases and apply schema
  4. Seed a default admin user
  5. Start the application

Check the logs for the generated admin password:

docker logs milestone-fresh

Access the application at http://localhost:8486/.

Production Deployment

For production with an external PostgreSQL instance:

1. Configure Environment

cp .env.example .env

Edit .env with your production settings:

# Database
DB_HOST=your-db-host
DB_PORT=5432
DB_NAME=milestone
DB_USER=milestone
DB_PASSWORD=<strong-password>

# Security (generate with: python -c "import secrets; print(secrets.token_hex(32))")
# REQUIRED: with DEBUG=false the server will not start on an empty or example value.
SESSION_SECRET=<64-char-random-string>

# HTTPS
SECURE_COOKIES=true

2. Set Up Database

psql -U postgres -f setup_databases.sql

Or manually:

CREATE DATABASE milestone;
CREATE USER milestone WITH PASSWORD 'your_password';
GRANT ALL PRIVILEGES ON DATABASE milestone TO milestone;
\c milestone
-- Run the tenant schema from setup_databases.sql

3. Start the Application

For a managed or external PostgreSQL (RDS, Azure Database, Cloud SQL, or your own server), use the external-DB compose file — it can auto-create the databases and seed an admin when PG_ADMIN_* credentials are provided:

docker compose -f docker-compose.external-db.yml up -d

docker-compose.yml is deployment-specific

The plain docker-compose.yml at the repository root targets a specific Unraid deployment — it references an external Docker network (guac-net) and a hardcoded host path, so docker compose up -d fails on machines without that setup. Use docker-compose.fresh.yml or docker-compose.external-db.yml instead, or copy docker-compose.yml and adapt the network and volume entries to your host.

4. Create First User

The simplest path is auto-initialization: set AUTO_INIT_DB=true (plus optional INIT_ADMIN_EMAIL / INIT_ADMIN_PASSWORD) before first start — the entrypoint creates the schema and seeds an admin, printing a generated password in the container logs if you didn't set one.

If the database was set up manually without auto-init, create or reset the admin with the helper script (it hashes the password correctly for you):

python scripts/setup_admin_password.py

5. Enable Multi-Tenant Mode (Optional)

See Multi-Tenant Management for SaaS deployment configuration.

GitHub Codespaces (Demo / Development)

Launch a fully configured development environment in your browser:

Open in GitHub Codespaces

The Codespace includes:

  • Python 3.11 with all backend dependencies
  • Node.js 24 with frontend dependencies
  • PostgreSQL 15 (auto-configured)
  • VS Code extensions for Python, TypeScript, Docker
  • Forwarded ports: 8485 (app), 3333 (Vite dev), 5432 (PostgreSQL)
  • Pre-seeded demo tenant with sample projects, staff, and equipment

The backend starts automatically on port 8485 (serving the pre-built frontend), so the demo is usable as soon as the Codespace is ready — no commands needed. For frontend development with hot reload, optionally start the Vite dev server:

cd frontend && npm run dev -- --host 0.0.0.0 --port 3333

Demo Credentials

Access URL Email Password
Admin Portal /admin/ [email protected] demo1234
Demo Tenant /t/demo/ [email protected] demo1234

The demo tenant includes:

  • 2 sites: Winterthur (CH), Frankfurt (DE), each with its public holidays and a shared year-end closure
  • 5 projects (one archived) with phases, nested subphases, dependencies, tags from two tag groups, custom columns and Kanban comments
  • 9 people with skills and site assignments — including a part-time member, a plain user who manages one project, someone on both sites and an inactive account
  • 8 equipment items (one shared by both sites) with bookings and maintenance blocks
  • 6 skills (Project Management, HPLC, Data Analysis, Cell Culture, Technical Writing, Quality Control)
  • Absences of every type, including a recurring Home Office pattern

All staff accounts use password demo1234.

Reverse Proxy (SSL/TLS)

For production, place a reverse proxy (nginx, Caddy, or Cloudflare Tunnel) in front of port 8485:

server {
    listen 443 ssl http2;
    server_name milestone.example.com;

    ssl_certificate     /etc/ssl/certs/milestone.pem;
    ssl_certificate_key /etc/ssl/private/milestone.key;

    location / {
        proxy_pass http://127.0.0.1:8485;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket support
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}
milestone.example.com {
    reverse_proxy localhost:8485
}

Set SECURE_COOKIES=true in .env when serving over HTTPS.

Trust the proxy's X-Forwarded-For (FORWARDED_ALLOW_IPS)

The container starts uvicorn with --proxy-headers --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-127.0.0.1}": the client address is taken from X-Forwarded-For only for requests coming from the listed addresses. When the proxy runs in another container or on another host (the usual case: nginx / Traefik / Nginx Proxy Manager on a Docker network), it is not 127.0.0.1, so by default every visitor appears to come from the proxy's address.

That matters for the failed-login lockout, which is keyed on client IP + e-mail (10 failures in 15 minutes → HTTP 429): if all clients share the proxy's address, ten wrong passwords from anyone lock that e-mail out for everyone — a way to block a known account. Set in .env:

# The proxy's IP address(es), comma-separated
FORWARDED_ALLOW_IPS=172.18.0.2
# Or trust any sender — ONLY if port 8485 is reachable solely through the proxy
# (private Docker network, port not published to the outside); otherwise clients
# could forge X-Forwarded-For and pick their own address.
# FORWARDED_ALLOW_IPS=*

A second limit, 100 failures in 15 minutes per client IP (any e-mail), stops password spraying from one address. It only applies to public client addresses — never to private, loopback or carrier-NAT ones, which a proxy or NAT may share between many users.

Ports

Port Service Description
8485 FastAPI Production application (API + frontend)
8486 FastAPI Fresh install default (configurable via FRESH_APP_PORT)
3333 Vite Frontend dev server with hot reload
5432 PostgreSQL Database (default)
5433 PostgreSQL Fresh install DB (configurable via FRESH_DB_PORT)

Network & Firewall Requirements

Milestone runs entirely on your infrastructure, but a few features make outbound calls from the server to the internet, and real-time collaboration uses a WebSocket. Organizations that block server-to-internet egress or WebSocket connections by default must allow the following.

Outbound URLs to allow (server → internet)

Allow the application server to reach these hosts over HTTPS (443):

Host Purpose Required?
nagerholidays.com Public/bank-holiday import for sites (GET /api/v4/Holidays/{country}/{year}) Optional — only when a site has a country code
login.microsoftonline.com Microsoft Entra ID sign-in — authorize + token endpoints Only if SSO is enabled
graph.microsoft.com Microsoft Graph — user profile and group membership Only if SSO is enabled

Holiday import fails soft

The holiday API base URL is configurable with NAGER_API_URL (default https://nagerholidays.com/api/v4). If the host is unreachable, holiday import is skipped and logged — creating or editing a site still succeeds; the site keeps whatever bank holidays it already had until it can reach the API.

Upgrading from a version before 1.1.0

Earlier releases defaulted to https://date.nager.at/api/v3. If your firewall allows only date.nager.at, add nagerholidays.com — otherwise holiday import stops working after the upgrade. A NAGER_API_URL pinned to a /api/v3 URL in your .env is still detected and used (a warning is logged), but version 3 of the holiday API reaches end of life on 2027-01-31, so plan to switch to https://nagerholidays.com/api/v4.

Outbound proxy coverage

The holiday API call honours the outbound-proxy settings (HTTP_PROXY, HTTPS_PROXY, PROXY_USERNAME/PROXY_PASSWORD, PROXY_PAC_URL, PROXY_CA_CERT, PROXY_VERIFY_SSL). The Microsoft Entra sign-in calls currently connect directly and do not route through those proxy variables — if all egress must go through a proxy, allow login.microsoftonline.com and graph.microsoft.com at the firewall/proxy level rather than relying solely on the app's proxy settings.

Inbound, only the application port needs to be reachable by users (see Ports) — typically fronted by your reverse proxy on 443.

WebSocket (real-time collaboration)

Milestone uses a WebSocket for live presence ("who's online") and to auto-refresh other users' views when data changes. It is same-origin — the same host and port as the app (8485), upgraded from HTTP — so there is no separate host or port to allow. The connection is wss:// when the app is served over HTTPS, ws:// otherwise, on paths /ws (single-tenant) and /t/{slug}/ws (multi-tenant).

For it to work through a reverse proxy or corporate proxy:

  • Pass the upgrade headers. Forward Upgrade: websocket and Connection: upgrade (see the nginx block under Reverse Proxy (SSL/TLS) — the proxy_http_version 1.1 + Upgrade/Connection lines).
  • Forward cookies on the handshake. Authentication uses the connect.sid session cookie sent with the upgrade request; a proxy that strips cookies will cause the connection to be rejected.
  • Use a generous idle timeout. The client sends a keepalive ping every ~25 seconds, so set the proxy's WebSocket/idle timeout to at least 60 seconds to avoid premature disconnects.
  • Keep the Host header (or set X-Forwarded-Host). The server refuses WebSocket handshakes whose Origin belongs to another site (cross-site WebSocket hijacking guard, close code 4003). The Origin host must equal the request's Host or X-Forwarded-Host, or be listed in CORS_ORIGINS. The nginx example above passes Host $host; a proxy that rewrites Host to the upstream address must send X-Forwarded-Host instead.

Milestone still works without WebSockets

If WebSocket connections are blocked, the application remains fully usable over normal HTTPS — you lose only live presence and automatic refresh (users reload to see others' changes). While live updates are disconnected the header shows an Offline badge. The client retries a few times with backoff, then waits and tries again as soon as the browser reports the network is back or the tab becomes visible; there is no long-polling fallback. As a last resort the frontend has a WEBSOCKET_DISABLED build flag that turns the real-time layer off entirely.