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:
- Database connectivity:
docker compose exec postgres pg_isreadyreturnsaccepting connections - Volume persistence: Create a test task, run
docker compose down && docker compose up -d, verify the task survives - TLS validity: SSL Labs test returns A+ rating with no certificate warnings
- Backup integrity:
docker compose exec postgres pg_dump -U frankboard frankboard > backup.sqlproduces a non-empty, parseable SQL file
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
- Docker Compose with named volumes and healthchecks provides a reproducible, recoverable deployment path for any VPS
- PostgreSQL in a dedicated container with explicit dependency ordering eliminates the race conditions common in simpler setups
- Caddy's automatic TLS removes certificate management overhead and reduces production failure modes
- Binding application containers to localhost interfaces and proxying through a dedicated edge container follows defense-in-depth principles
- FrankBoard's single-container design and stateless architecture make it particularly well-suited to this deployment model, with no build step or external service dependencies beyond the database