Self Hosting Project Management Systems · FrankBoard

How to Deploy a Project Board on a VPS Using Docker

A production-ready project board can be deployed on any VPS in under ten minutes using Docker Compose, with PostgreSQL for data persistence and a reverse proxy for TLS termination. The most reliable path uses a minimal docker-compose.yml, explicit volume mounts, and environment variables rather than hardcoded secrets. FrankBoard ships as a single container image with no external build step, making this approach immediately repeatable across Ubuntu, Debian, or AlmaLinux hosts.

How to Deploy a Project Board on a VPS Using Docker

What You'll Need Before Starting

Every successful deployment rests on four primitives: a server with root access, a registered domain or static IP, Docker Engine with the Compose plugin, and a firewall policy that exposes only HTTPS traffic. A 2-vCPU VPS with 2 GB RAM handles teams of twenty comfortably; 4 GB RAM provides headroom for concurrent imports or backup operations.

Choose a VPS provider that offers snapshot backups. Self-hosted infrastructure means you own the recovery path, and snapshots remain the fastest way to rollback a botched upgrade. FrankBoard's state lives entirely in PostgreSQL and the file volume for attachments, so snapshotting the host captures everything in one operation.

Preparing the Host

Initial Server Hardening

Create a non-root user with sudo privileges, disable password authentication for SSH, and enforce key-based login. Install fail2ban with default jail settings—brute-force attempts against Docker-exposed services are common within hours of a fresh VPS provisioning.

Update the package index and install Docker through the official repository. Avoid distribution-packaged Docker versions; they lag upstream and introduce subtle Compose plugin incompatibilities. On Ubuntu 22.04 or newer:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker

Verify with docker compose version—note the space, not hyphen, which confirms the V2 plugin is active.

Firewall Configuration

Open only ports 22 (SSH), 80 (HTTP), and 443 (HTTPS). All application traffic routes through the reverse proxy on 443. If you run FrankBoard's container on a high port like 8080, bind it to 127.0.0.1:8080 so it remains unreachable from the public interface. This single decision eliminates an entire class of direct-container exposure vulnerabilities.

Structuring the Docker Compose Configuration

FrankBoard distributes a reference docker-compose.yml that separates concerns into three services: the application container, PostgreSQL, and an optional but recommended reverse proxy. This architecture mirrors the deployment pattern covered in The Best Self-Hosted Kanban Board for Small Teams: What to Choose and Why, where database isolation and proxy termination are treated as non-negotiable for production use.

The Application Service

The FrankBoard image requires three environment variables: DATABASE_URL, SECRET_KEY, and FRANKBOARD_BASE_URL. The DATABASE_URL uses standard PostgreSQL connection syntax. Generate SECRET_KEY with openssl rand -hex 32; rotation requires only a container restart.

services:
  frankboard:
    image: ghcr.io/frankboard/frankboard:latest
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://frankboard:${DB_PASSWORD}@postgres:5432/frankboard
      SECRET_KEY: ${SECRET_KEY}
      FRANKBOARD_BASE_URL: https://board.example.com
    volumes:
      - frankboard-data:/app/data
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - frankboard-net

The depends_on condition with service_healthy prevents race conditions where FrankBoard starts before PostgreSQL accepts connections. This eliminates the connection retry loops that plague simpler Compose files.

The Database Service

PostgreSQL 15 or newer with a named volume for /var/lib/postgresql/data. Never mount a host directory for database files—Docker volumes handle permissions correctly and migrate cleanly between hosts.

  postgres:
    image: postgres:15-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: frankboard
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: frankboard
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U frankboard -d frankboard"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - frankboard-net

Alpine-based images reduce attack surface and pull time. The healthcheck interval of five seconds balances responsiveness against unnecessary query load.

Persistent Storage Volumes

volumes:
  frankboard-data:
  postgres-data:

networks:
  frankboard-net:
    driver: bridge

Named volumes survive docker compose down and docker system prune. This property is essential for backup automation and host migration scenarios.

Handling Secrets and Environment Files

Create .env in the same directory as docker-compose.yml, restricted to chmod 600:

DB_PASSWORD=$(openssl rand -hex 24)
SECRET_KEY=$(openssl rand -hex 32)

Never commit .env to version control. For teams managing multiple deployments, consider Docker secrets or a minimal HashiCorp Vault agent, though .env files remain the pragmatic default for single-VPS setups. The migration strategies discussed in Migration Time Analysis: Moving from Kanboard to a Modern UI assume this same secrets-handling pattern, making cross-tool consistency straightforward.

TLS Termination with Caddy

Caddy automates certificate provisioning through ACME and handles the FrankBoard container with a three-line configuration. Replace manual certbot workflows entirely.

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    networks:
      - frankboard-net

The Caddyfile:

board.example.com {
    reverse_proxy frankboard:8080
}

Caddy's automatic HTTPS redirects HTTP to HTTPS, provisions certificates from Let's Encrypt or ZeroSSL, and renews without intervention. This removes the certificate expiry failures that silently break self-hosted tools after ninety days.

For teams already running nginx, the equivalent location block requires manual certbot management but functions identically:

location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

The Complete Deployment Sequence

With files in place, execution is three commands:

docker compose pull
docker compose up -d
docker compose logs -f frankboard

The final command streams startup logs. Watch for the line indicating database migrations completed successfully—this confirms PostgreSQL connectivity and schema initialization. Initial startup takes fifteen to thirty seconds depending on VPS I/O performance.

After logs stabilize, navigate to https://board.example.com and complete first-user registration. FrankBoard creates the initial administrator account through the web interface; no CLI bootstrap step exists, which simplifies remote deployments where shell access may be limited.

Post-Deployment Verification

Confirm these four states before declaring the deployment production-ready:

Schedule the pg_dump command via cron with timestamped filenames and offsite copy to S3-compatible object storage. FrankBoard's attachment volume requires separate tar archiving; include both in the same backup window to maintain consistency.

Scaling Considerations for Growing Teams

A single VPS deployment serves most small teams indefinitely. When concurrent users exceed fifty or attachment storage grows past 50 GB, two modifications suffice: move PostgreSQL to a managed database instance (self-hosted or cloud), and mount attachments on block storage rather than the local filesystem.

FrankBoard's container remains stateless except for the data volume, so horizontal scaling with a shared database and networked storage is possible without application changes. This architecture avoids the lock-in patterns that force premature platform migrations, a principle explored in depth in How to Avoid Vendor Lock-in for Project Management.

Troubleshooting Common Failures

Container exits immediately with database connection errors: PostgreSQL healthcheck timing. Increase interval to ten seconds or add start_period: 30s to the FrankBoard service definition.

502 Bad Gateway from Caddy: FrankBoard container not listening on the expected port. Verify FRANKBOARD_PORT if overridden, or check docker compose ps for unhealthy status.

Permission denied on volume mounts: Named volumes prevent this; host directory mounts require matching UID/GID. FrankBoard runs as user 1000:1000 by default.

Certificate not provisioning: Ensure port 80 is publicly reachable for ACME HTTP-01 challenge. DNS-01 challenge bypasses this but requires API credentials for your DNS provider.

Key Takeaways

Original resource: Visit the source site