PowerSolver Docs
Back to Wizard
This page belongs to the full documentation. You don't need it to plan: back to the essentials.

Hetzner VPS Guide: Installing Alpine Linux and Deploying the PowerSolver API

Table of Contents


Overview

This guide walks you through installing Alpine Linux on your Hetzner VPS and deploying the PowerSolver API Docker image. Alpine Linux is a lightweight, security-oriented distribution perfect for containerized applications.


Prerequisites

✅ What You Need:

  • ☑ Hetzner VPS (CPX22 or similar)
  • ☑ Access to Hetzner Cloud Console
  • ☑ SSH access to your VPS (after installation)
  • ☑ Basic knowledge of Linux commands

Part 1: Installing Alpine Linux on Hetzner VPS

Step 1: Access Hetzner Cloud Console

  1. Log in to your Hetzner Cloud Console
  2. Navigate to your server (e.g., CPX22)
  3. Click on your server to view details

Step 2: Mount Alpine Linux ISO Image

  1. In the server details page, click on the "ISO Images" tab
  2. You'll see a list of available ISO images
  3. Find "Alpine Virtual" in the list (recommended: Alpine Virtual 3.22.2 or latest)
  4. Click the "Mount" button next to the Alpine ISO image
    • Example: Alpine Virtual 3.22.2 (x86_64) | ID: 122881

Important: The ISO will be attached to your server's virtual drive.

Step 3: Reboot Server from ISO

  1. After mounting the ISO, you need to reboot the server
  2. Go to the "Power" tab in your server details
  3. Click "Reboot" or toggle the power switch to OFF then ON
  4. The server will now boot from the Alpine ISO image

Note: Some ISO images may require you to press a key in the console during boot. Otherwise, the server will boot from disk again.

Step 4: Access Web Console

  1. After reboot, click on the "Console" tab (or terminal icon) in Hetzner Cloud Console
  2. You should see the Alpine Linux boot process
  3. Wait for the login prompt: localhost login:

⚠️ IMPORTANT: At this point, Alpine Linux is running from the ISO image (live mode). You need to install it to disk before rebooting again. If you reboot now without installing, you'll just boot back to the ISO.

Step 5: Install Alpine Linux to Disk

This is the critical step! You must install Alpine Linux to your server's disk before rebooting.

  1. Login: At the localhost login: prompt, type:
root

Press Enter. There is no password at this stage (just press Enter if prompted).

  1. Verify you're in live mode: You should see a prompt like:
localhost:~#
  1. ⚠️ CRITICAL: Run the installation script:
setup-alpine

DO NOT skip this step! This will install Alpine Linux to your disk.

  1. Follow the installation wizard: Answer the prompts as follows:
  • Keyboard layout: Press Enter for default (us) or type your layout
  • Hostname: Enter a hostname (e.g., timefold-server) and press Enter
  • Network interface: Usually eth0 - press Enter if correct, or type the correct interface
  • IP address: Type dhcp and press Enter (for automatic IP configuration)
  • DNS: Press Enter for default, or type custom DNS servers (e.g., 1.1.1.1)
  • Root password: IMPORTANT: Enter a strong password and remember it! You'll need this to log in after installation.
  • Time zone: Type your timezone (e.g., UTC, Europe/Helsinki, America/New_York) or press Enter for default
  • Proxy: Press Enter (leave blank) unless you need a proxy
  • NTP client: Type chrony (recommended) or busybox, then press Enter
  • SSH server: Type openssh and press Enter (important for remote access!)
  • Disk: The installer will show available disks. Usually sda or vda - type the disk name and press Enter
  • Mode: Type sys and press Enter (for a standard installation)
  • Erase disk: WARNING: Type y and press Enter to confirm. This will erase all existing data on the disk!
  1. Wait for installation: The installation will take 2-5 minutes. You'll see progress messages.
  1. Installation complete: When you see a message like "Installation is complete. Please reboot.", DO NOT reboot yet!

Step 6: Unmount ISO Before Rebooting

⚠️ CRITICAL: You MUST unmount the ISO image BEFORE rebooting, otherwise the server will keep booting from the ISO instead of the installed system!

  1. Unmount ISO in Hetzner Console (DO THIS NOW):
    • Open a new browser tab or window
    • Go to Hetzner Cloud Console
    • Navigate to your server
    • Click on the "ISO Images" tab
    • Click "Unmount" or "Eject" button next to the Alpine ISO image
    • Wait for confirmation that the ISO is unmounted
  1. Now reboot: Go back to your console tab and run:
reboot

This reboot will now boot from the installed Alpine Linux on disk, not from the ISO.

Step 7: Verify Installation

  1. After reboot, log in:
    • You should see localhost login: prompt again
    • Type root and press Enter
    • Enter the password you set during installation (not empty this time!)
  1. Verify network connectivity:

Option 1: Using busybox ping (default in Alpine):

ping -c 3 google.com

Option 2: If you get "invalid number" error, use this syntax:

ping google.com

(Press Ctrl+C after a few seconds to stop)

Option 3: Install full ping package for better compatibility:

apk add iputils-ping
ping -c 3 google.com

Option 4: Test with IP address directly:

ping -c 3 8.8.8.8
  1. Check IP address:
ip addr show
  1. Update the system:
apk update && apk upgrade

Part 2: Connect via SSH from Your Laptop (Windows)

Why use SSH instead of Hetzner Console?

  • ✅ Copy/paste works properly
  • ✅ Better terminal experience
  • ✅ Can use your preferred SSH client
  • ✅ Easier file transfers (SCP/SFTP)

Get Your Server's IP Address

  1. From Hetzner Console:
    • Go to Hetzner Cloud Console
    • Click on your server
    • Go to "Overview" tab
    • Find "IPv4" address (e.g., 95.217.14.18)
  1. Or from the server console:
ip addr show eth0 | grep "inet "

Look for the line with inet - that's your IP address.

Connect via SSH from Windows

Option A: Using PowerShell (Windows 10/11 - Built-in)

  1. Open PowerShell (Press Win + X → Select "Windows PowerShell" or "Terminal")
  1. Connect to your server:
ssh root@YOUR_SERVER_IP

Replace YOUR_SERVER_IP with your actual IP (e.g., 95.217.14.18)

  1. First connection warning: You'll see a message like:
The authenticity of host '95.217.14.18 (95.217.14.18)' can't be established.
ECDSA key fingerprint is SHA256:...
Are you sure you want to continue connecting (yes/no/[fingerprint])?

Type yes and press Enter.

  1. Enter password: Enter the root password you set during Alpine installation.
  1. You're connected! You should see:
Welcome to Alpine Linux...
localhost:~#

Option B: Using PuTTY (Alternative SSH Client)

  1. Download PuTTY:
  1. Configure connection:
    • Open PuTTY
    • Host Name (or IP address): Enter your server IP (e.g., 95.217.14.18)
    • Port: 22 (default SSH port)
    • Connection type: Select SSH
    • Saved Sessions: Type a name (e.g., "Hetzner Server") and click Save
  1. Connect:
    • Click Open
    • First time: Click Yes to accept the server's host key
    • Login as: root
    • Enter your password when prompted

Option C: Using Windows Terminal (Recommended for Windows 11)

  1. Open Windows Terminal (Press Win + X → Select "Terminal")
  1. Add SSH profile (optional):
    • Click the dropdown arrow next to the + button
    • Select "Settings" → "Add new profile"
    • Name: Hetzner Server
    • Command line: ssh root@YOUR_SERVER_IP
    • Save
  1. Connect: Select the profile or run:
ssh root@YOUR_SERVER_IP

Step 3: Verify SSH Connection

Once connected, verify everything works:

# Check you're logged in as root
whoami

# Check IP address
ip addr show eth0

# Test copy/paste (try copying this command and pasting it)
echo "SSH connection works!"

# Check system info
uname -a

SSH Troubleshooting

Problem 1: "Connection refused" or "Connection timed out"

Quick Fix - Complete SSH Setup:

# Run from Hetzner Console (since SSH isn't working)
apk add openssh
ssh-keygen -A
rc-service sshd start
rc-update add sshd boot

# Enable password authentication
sed -i 's/^#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
sed -i 's/^PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config
sed -i 's/^PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config

# Restart SSH
rc-service sshd restart

# Verify it's running and listening
rc-service sshd status
netstat -tlnp | grep :22

If still not working, check:

  1. Hetzner Network Firewall (most common):
    • Hetzner Console → Server → Networking tab
    • Ensure firewall allows TCP port 22 (inbound)
  1. Alpine Firewall:
rc-service iptables status
# If active, allow SSH:
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables-save > /etc/iptables/rules.v4
  1. SSH listening on correct interface:
netstat -tlnp | grep :22
# Should show 0.0.0.0:22, NOT 127.0.0.1:22
  1. Correct IP address:
curl -4 ifconfig.me

Problem 2: "Permission denied" (Password Authentication Failing)

Quick Fix:

# Enable password authentication and root login
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup
sed -i 's/^#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
sed -i 's/^PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config
sed -i 's/^PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
rc-service sshd restart

# Reset root password
passwd root
# Enter new password twice (use simple password for testing)

Verify configuration:

cat /etc/ssh/sshd_config | grep -i "PasswordAuthentication\|PermitRootLogin"
# Should show: PasswordAuthentication yes and PermitRootLogin yes

Common causes:

  • PasswordAuthentication disabled (most common)
  • PermitRootLogin disabled
  • Wrong password or special character issues
  • Account locked

Note: Make sure you're checking /etc/ssh/sshd_config (server config), not /etc/ssh/ssh_config (client config).

Next Steps

Now that you're connected via SSH, you can:

  • ✅ Copy/paste commands easily
  • ✅ Use SCP to transfer files (see Part 3)
  • ✅ Continue with Docker installation (Part 2)

Continue with the guide below...


Part 3: Setting Up Docker on Alpine Linux

Step 1: Install Required Prerequisites

# Update package index
apk update

# Install required tools and dependencies
apk add curl wget git bash openssh-client iputils-ping

Install Docker Engine

⚠️ Important: Alpine Linux doesn't include Docker Engine in the default stable repositories. Use the Alpine Edge repository (recommended):

# Add Alpine edge repository (contains Docker)
echo "http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories

# Update package index
apk update

# Install Docker from edge repository
apk add docker docker-cli docker-compose

# Verify installation
docker --version
docker-compose --version

Note: If you want to remove the edge repository after installation for security:

sed -i '/edge\/community/d' /etc/apk/repositories
apk update

Start Docker Service

# Start Docker service
service docker start

# Wait a few seconds for Docker to start
sleep 3

# Check Docker service status
service docker status

# Enable Docker to start on boot
rc-update add docker boot

# Verify Docker is running
docker --version
docker ps

# If docker ps works, Docker daemon is running correctly!

⚠️ Troubleshooting: If you get "Cannot connect to the Docker daemon" error:

# Check if Docker service is actually running
service docker status

# If not running, check for errors
rc-service docker status

# Check Docker logs for errors
tail -20 /var/log/docker.log

# Try starting Docker manually
service docker start

# Wait and verify again
sleep 5
docker ps

# Check if Docker daemon process is running
ps aux | grep dockerd

# Check Docker socket permissions
ls -la /var/run/docker.sock

Note: If you get "docker: command not found", make sure Docker was installed successfully. Check with:

which docker
ls -la /usr/bin/docker

Step 4: Configure Docker (Optional)

Create Docker daemon configuration for better performance:

# Create Docker daemon config directory
mkdir -p /etc/docker

# Create daemon.json
cat > /etc/docker/daemon.json << EOF
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  },
  "storage-driver": "overlay2"
}
EOF

# Restart Docker to apply changes
service docker restart

Verify Docker Installation

# Check Docker version
docker --version

# Check Docker Compose version
docker-compose --version
# or
docker compose version

# Test Docker with hello-world
docker run hello-world

Part 4: Deploying the PowerSolver API

Rename Docker Image (If Needed)

If your local image has a different name, rename it first:

Windows PowerShell:

# List existing images to find the current name
docker images

# Rename/tag the image (replace timefold-api-timefold-api with your actual image name)
docker tag timefold-api-timefold-api:latest powersolver-timefold-api:latest

# Verify the new image exists
docker images powersolver-timefold-api

# Remove the old tag (optional - keeps only the new name)
docker rmi timefold-api-timefold-api:latest

# Verify only the renamed image remains
docker images

Note: The docker tag command creates a new tag pointing to the same image (doesn't duplicate it). The docker rmi command only removes the tag, not the image data, so it's safe.

Save Docker Image from Local Machine

On your local machine (where the working images exist), save both Docker images to files:

Windows PowerShell:

# List your Docker images to verify they exist
docker images

# You should see:
# - powersolver-timefold-api:latest (or timefold-api-timefold-api:latest)
# - redis:7-alpine

# Save the PowerSolver API image to a tar file
docker save powersolver-timefold-api:latest -o powersolver-timefold-api.tar

# Save the Redis image to a tar file
docker save redis:7-alpine -o redis-7-alpine.tar

# Verify both files were created
ls powersolver-timefold-api.tar, redis-7-alpine.tar

# Check file sizes (they will be large)
(Get-Item powersolver-timefold-api.tar).Length / 1MB
(Get-Item redis-7-alpine.tar).Length / 1MB

Note:

  • Make sure you have the working PowerSolver API image locally with the correct name (powersolver-timefold-api:latest). If you see timefold-api-timefold-api:latest instead, rename it using the steps above or build it:
cd D:\PowerSolver\timefold-api
docker build -t powersolver-timefold-api:latest .
  • The redis:7-alpine image should already exist if you've run docker-compose before. If not, pull it:
docker pull redis:7-alpine

Alternative: Save Both Images in One File (Recommended)

# Save both images together in a single tar file (more efficient)
docker save powersolver-timefold-api:latest redis:7-alpine -o timefold-images.tar

# Verify the file was created
ls timefold-images.tar

# Check file size
(Get-Item timefold-images.tar).Length / 1MB

Transfer Docker Image to Server

Transfer the saved image file(s) to your Hetzner server:

Using SCP (PowerShell) - If you saved images separately:

# Transfer both image files to server
scp powersolver-timefold-api.tar root@YOUR_SERVER_IP:/root/
scp redis-7-alpine.tar root@YOUR_SERVER_IP:/root/

# This may take several minutes depending on file size and connection speed

Using SCP (PowerShell) - If you saved images together (Recommended):

# Transfer the combined image file to server
scp timefold-images.tar root@YOUR_SERVER_IP:/root/

# This may take several minutes depending on file size and connection speed

Using WinSCP or FileZilla:

  • Connect to your server via SFTP
  • Upload the image file(s) to /root/ directory:
    • powersolver-timefold-api.tar and redis-7-alpine.tar (if saved separately)
    • OR timefold-images.tar (if saved together - recommended)

Load Docker Image on Server

⚠️ Important: Should You Remove Existing Images?

First-time deployment: No need to remove anything - just load the images.

Updating existing images: You have two options:

Option A: Remove old images first (Recommended for updates)

  • Prevents disk space waste from duplicate images
  • Ensures you're using the newly loaded images
  • Safe if containers are stopped

Option B: Load without removing (Simpler)

  • Docker will load the new images
  • Old images become "dangling" (untagged) but still take space
  • You can clean them up later with docker image prune

Check existing images before loading:

# List existing images
docker images

# Check if containers are using the images
docker ps -a

If you want to remove old images before loading (Recommended for updates):

# Stop any running containers first
docker-compose down 2>/dev/null || true
docker stop $(docker ps -aq) 2>/dev/null || true

# Remove old images (if they exist)
docker rmi powersolver-timefold-api:latest 2>/dev/null || true
docker rmi redis:7-alpine 2>/dev/null || true

# Remove dangling/untagged images (optional cleanup)
docker image prune -f

Now load the transferred image(s):

If you transferred images separately:

# Load the PowerSolver API image
docker load -i /root/powersolver-timefold-api.tar

# Load the Redis image
docker load -i /root/redis-7-alpine.tar

# Verify both images were loaded
docker images powersolver-timefold-api
docker images redis

# You should see:
# REPOSITORY                    TAG       IMAGE ID       CREATED         SIZE
# powersolver-timefold-api      latest    xxxxxxxxxx     X hours ago     XXX MB
# redis                         7-alpine   xxxxxxxxxx     X days ago      XX MB

If you transferred images together (Recommended):

# Load both images from the combined tar file
docker load -i /root/timefold-images.tar

# Verify both images were loaded
docker images powersolver-timefold-api
docker images redis

# You should see both images listed

Clean up tar files after loading (optional):

# Remove the tar files to save disk space
rm /root/powersolver-timefold-api.tar /root/redis-7-alpine.tar
# OR if you used combined file:
rm /root/timefold-images.tar

Clean Up Docker (Optional)

Before deploying, optionally clean up existing containers and images:

# Stop all running containers
docker stop $(docker ps -aq) 2>/dev/null || true

# Remove all containers
docker rm $(docker ps -aq) 2>/dev/null || true

# Remove old images (optional - keep if you want to preserve them)
docker image prune -a

# Verify cleanup
docker ps -a    # Should show no containers

Deploy with Docker Compose

Step 1: Create Docker Compose Configuration for Production

Create docker-compose.yml for production deployment:

cd /root

# Create docker-compose.yml for production
cat > docker-compose.yml << 'EOF'
services:
  timefold-api:
    image: powersolver-timefold-api:latest
    container_name: powersolver-api
    ports:
      - "8001:8001"
    environment:
      - JAVA_OPTS=-Xmx2048m -Xms512m
    restart: unless-stopped
    depends_on:
      - redis
    networks:
      - timefold-network

  redis:
    image: redis:7-alpine
    container_name: powersolver-redis
    ports:
      - "6379:6379"
    restart: unless-stopped
    networks:
      - timefold-network
    volumes:
      - redis-data:/data

networks:
  timefold-network:
    driver: bridge

volumes:
  redis-data:
EOF

Step 2: Start Production Services with Docker Compose

# Start all services in production mode (detached)
docker-compose up -d

# Or if using Docker Compose plugin (newer versions)
docker compose up -d

# Check service status
docker-compose ps
# or
docker compose ps

Expected output:

NAME                  IMAGE                          STATUS
powersolver-api       powersolver-timefold-api:latest Up X seconds
powersolver-redis     redis:7-alpine                 Up X seconds

Step 3: Verify Production Deployment

# Check container status
docker ps

# Check logs (follow logs in real-time)
docker-compose logs -f timefold-api

# Or check logs without following
docker-compose logs timefold-api

# Test API health endpoint
curl http://localhost:8001/api/health

# Expected response:
# {"status":"UP","service":"PowerSolver API","version":"2.0.0"}

✅ Production Deployment Complete!

Your PowerSolver API is now running in production mode with:

  • ✅ Docker image loaded from local machine
  • ✅ API service running on port 8001
  • ✅ Redis service running on port 6379
  • ✅ Automatic restart on failure (restart: unless-stopped)
  • ✅ Persistent Redis data storage
  • ✅ Network isolation between services

Next Steps:

Expose Container to Internet

Now that your production services are running, make them accessible from the internet:

⚠️ IMPORTANT: the wizard is served over HTTPS, so the API must be reachable over HTTPS too (Nginx + Let's Encrypt, below). Test basic connectivity with HTTP first if you like.

8.1: Get Your Server's Public IP Address

# Get IPv4 address
curl -4 ifconfig.me
# or
curl -4 icanhazip.com
# or check Hetzner console for IP: 95.217.14.18 (from your server info)

# Get all IP addresses
ip addr show | grep "inet "

# Your public IP should be visible in Hetzner Cloud Console
# Server → Overview → IP addresses

8.2: Verify Production Container is Running

Ensure your production containers are running:

# If using Docker Compose (recommended)
docker-compose up -d

# Verify containers are running
docker-compose ps

# Check if port 8001 is listening
netstat -tuln | grep 8001
# or
ss -tuln | grep 8001

8.3: Configure Firewall to Allow Port 8001

⚠️ IMPORTANT: Use INBOUND Rules

To allow external access to your container, you need INBOUND firewall rules (not outbound). Inbound rules control traffic coming INTO your server from the internet.

Option A: Use Hetzner Cloud Firewall (Recommended - Easier)

  1. Go to Hetzner Cloud Console → Firewalls
  2. Create a new firewall or edit existing one
  3. Click on "Inbound Rules" tab (or "Rules" → "Inbound")
  4. Add INBOUND rules:
    • SSH (22/tcp) - Allow from your IP (required for server access!)
    • HTTP (80/tcp) - Allow from 0.0.0.0/0 (needed for Let's Encrypt verification)
    • HTTPS (443/tcp) - Allow from 0.0.0.0/0 (public access via Nginx reverse proxy)
    • PowerSolver API (8001/tcp) - Optional: Allow only from localhost (127.0.0.1) if using Nginx reverse proxy, or keep public if accessing directly
  5. Click "Apply to Resources" → Select your server
  6. Done! No need to configure iptables on the server.

Visual Guide:

Internet → [INBOUND Rule: Allow 8001/tcp] → Your Server → Container (port 8001)

Note: You do NOT need outbound rules for this. Outbound rules control traffic going OUT from your server (like when your server makes requests to external APIs).

Option B: Configure Alpine iptables Firewall

Note: In iptables, INPUT chain = INBOUND traffic (traffic coming INTO your server)

# Install iptables if not installed
apk add iptables

# Allow SSH (CRITICAL - do this first!)
# INPUT chain = INBOUND traffic (from internet to server)
iptables -A INPUT -p tcp --dport 22 -j ACCEPT

# Allow PowerSolver API port 8001
# INPUT chain = INBOUND traffic (from internet to server)
iptables -A INPUT -p tcp --dport 8001 -j ACCEPT

# Allow established connections (for responses to outbound requests)
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# Save rules
apk add iptables-openrc
mkdir -p /etc/iptables
iptables-save > /etc/iptables/rules.v4

# Enable iptables service
rc-update add iptables boot

iptables Chain Explanation:

  • INPUT = INBOUND traffic (from internet → your server) ← Use this for port 8001
  • OUTPUT = OUTBOUND traffic (from your server → internet) ← Usually allowed by default
  • FORWARD = Traffic passing through (for routing) ← Not needed for this

8.4: Test Access from Internet

Step-by-Step Testing Guide:

Test 1: Verify Container is Running (On Server)

# On your server, check if container is running
docker ps

# Should show your container with status "Up"
# Example output:
# CONTAINER ID   IMAGE                          STATUS         PORTS                    NAMES
# abc123def456   powersolver-timefold-api:latest   Up 5 minutes   0.0.0.0:8001->8001/tcp   powersolver-api

Test 2: Verify Port is Listening (On Server)

# Check if port 8001 is listening and bound to all interfaces (0.0.0.0)
netstat -tuln | grep 8001
# Should show: tcp 0.0.0.0:8001 or tcp :::8001

# Alternative command
ss -tuln | grep 8001
# Should show: LISTEN 0 128 0.0.0.0:8001 or :::8001

# If you see 127.0.0.1:8001 instead of 0.0.0.0:8001, the port is only listening locally!

Test 3: Test from Server Itself (Local Test)

# On your server, test localhost access
curl http://localhost:8001/api/health

# Expected response:
# {"status":"UP","service":"PowerSolver API","version":"2.0.0"}

# If this works, the container is running correctly

Test 4: Test from Your Local Computer (Windows)

⚠️ NOTE: For Excel Online compatibility, you MUST use HTTPS. See Part 5, Step 2 for HTTPS setup. The tests below are for initial verification before HTTPS is configured.

Method A: Using PowerShell (Recommended)

# After HTTPS setup (recommended):
Invoke-RestMethod -Uri "https://api.planningpowertools.com/api/health"

# Before HTTPS setup (initial testing only):
# $serverIP = "95.217.14.18"
# Invoke-RestMethod -Uri "http://$serverIP:8001/api/health"

# Expected output:
# status  service                    version
# ------  -------                    -------
# UP      PowerSolver API   2.0.0

# If you get an error, check:
# - Is firewall configured? (Step 8.3)
# - Is container running? (docker ps on server)
# - Is port listening? (netstat on server)
# - Is HTTPS configured? (Part 5, Step 2)

Method B: Using Web Browser

1. Open your web browser (Chrome, Firefox, Edge, etc.)
2. Navigate to: https://api.planningpowertools.com/api/health
   (Or http://95.217.14.18:8001/api/health for initial testing before HTTPS setup)
3. You should see JSON response:
   {"status":"UP","service":"PowerSolver API","version":"2.0.0"}

If you see:
- "This site can't be reached" → Firewall not configured or container not running
- "Connection refused" → Port not listening or firewall blocking
- "Timeout" → Firewall blocking or wrong IP address
- "Not secure" or certificate error → HTTPS not configured (see Part 5, Step 2)

Method C: Using Command Prompt (if curl is installed)

# After HTTPS setup (recommended):
curl https://api.planningpowertools.com/api/health

# Before HTTPS setup (initial testing only):
# curl http://95.217.14.18:8001/api/health

# Expected: JSON response with status "UP"

Method D: Using Online Tools

1. Go to: https://www.yougetsignal.com/tools/open-ports/
2. Enter your server IP: 95.217.14.18
3. Enter port: 8001
4. Click "Check"
5. Should show: "Port 8001 is open"

Alternative tools:
- https://www.canyouseeme.org/
- https://portchecker.co/

Test 5: Test API Endpoints

# From Windows PowerShell, test different endpoints (HTTPS required):

# Health check
Invoke-RestMethod -Uri "https://api.planningpowertools.com/api/health"

# API documentation
Invoke-WebRequest -Uri "https://api.planningpowertools.com/v3/api-docs" | Select-Object -ExpandProperty Content

# Swagger UI (if available)
Start-Process "https://api.planningpowertools.com/swagger-ui.html"

Test 6: Test from Mobile Device or Another Network

1. Connect to a different network (mobile data, different WiFi)
2. Open browser on your phone
3. Navigate to: https://api.planningpowertools.com/api/health
4. Should see the same JSON response

This confirms your API is truly accessible from the internet via HTTPS!

8.5: Troubleshooting Failed Tests

⚠️ IMPORTANT: Can't Ping Server?

This is NORMAL! Hetzner (and most cloud providers) block ICMP ping by default. This does NOT affect HTTP/HTTPS access. Don't use ping to test - use HTTP instead!

# ❌ DON'T DO THIS (will timeout - this is normal!):
ping 95.217.14.18

# ✅ DO THIS INSTEAD (test HTTPS access):
Invoke-RestMethod -Uri "https://api.planningpowertools.com/api/health"

If Test 4 fails (can't access from your computer) - Step-by-Step Diagnosis:

Step 1: Verify Firewall is Applied to Your Server

⚠️ CRITICAL: The firewall rule must be applied to your server!

  1. Go to Hetzner Cloud Console → Firewalls → Click on your firewall
  2. Click on "Resources" tab (next to "Rules")
  3. Check if your server is listed under "Applied to"
  4. If NOT listed:
    • Click "Add Resource"
    • Select your server
    • Click "Add"
    • Wait a few seconds for it to apply

Step 2: Verify Container is Running

# On your server, check if container is running
docker ps

# Should show your container with status "Up"
# If not running, start it:
docker-compose up -d

Step 3: Verify Port is Listening Correctly

# Check if port 8001 is listening on 0.0.0.0 (all interfaces)
netstat -tuln | grep 8001

# Should show: tcp 0.0.0.0:8001 or tcp :::8001
# If you see 127.0.0.1:8001, the port is only listening locally!

# Alternative check
ss -tuln | grep 8001

Step 4: Test from Server Itself

# Test localhost access
curl http://localhost:8001/api/health

# If this fails, the container has an issue (not firewall)
# Check logs:
docker-compose logs timefold-api | tail -50

Step 5: Verify Firewall Rule Configuration

In Hetzner Console → Firewalls → Your Firewall → Rules tab:

✅ INBOUND Rules should have:

  • Protocol: TCP
  • Port: 8001
  • Source: Any IPv4 (or your specific IP)
  • Action: Allow (green checkmark)

❌ Common Mistakes:

  • Rule is in OUTBOUND instead of INBOUND
  • Wrong port number
  • Action is "Deny" instead of "Allow"
  • Firewall not applied to server (check Resources tab!)

Step 6: Check for Conflicting Rules

# If you also configured iptables on the server, check it's not blocking
iptables -L INPUT -n -v | grep 8001

# If you see DROP rules, they might conflict with Hetzner firewall
# Consider removing iptables rules if using Hetzner firewall

Step 7: Verify Server IP Address

# On your server, get the public IP
curl -4 ifconfig.me

# Make sure you're testing with the correct IP address
# Check Hetzner Console → Your Server → Overview → IP addresses

Step 8: Test Firewall Rule Order

In Hetzner Firewall, rules are processed top-to-bottom:

  • If you have a "Deny All" rule at the top, it will block everything
  • Make sure your "Allow 8001" rule comes BEFORE any "Deny All" rules
  • Or ensure there's no "Deny All" rule at all

Step 9: Wait for Firewall to Apply

After making changes:

  • Wait 10-30 seconds for firewall rules to propagate
  • Try accessing again

Step 10: Test from Different Location

# Test from your local computer
# Try both PowerShell and browser

# PowerShell (HTTPS required):
Invoke-RestMethod -Uri "https://api.planningpowertools.com/api/health"

# Browser:
# https://api.planningpowertools.com/api/health

Step 11: Check Container Port Binding

# Verify docker-compose.yml has correct port mapping
cat docker-compose.yml | grep -A 5 ports

# Should show:
# ports:
#   - "8001:8001"
# NOT:
#   - "127.0.0.1:8001:8001"  ← This would only allow localhost!

# If wrong, fix it and restart:
docker-compose down
docker-compose up -d

Common Issues and Quick Fixes:

Issue 1: "Connection refused" or "This site can't be reached"

Checklist:

# 1. Container running?
docker ps
# If not: docker-compose up -d

# 2. Port listening on 0.0.0.0?
netstat -tuln | grep 8001
# Should show 0.0.0.0:8001, NOT 127.0.0.1:8001

# 3. Firewall applied to server?
# Go to Hetzner Console → Firewalls → Resources tab → Check server is listed

# 4. Firewall rule correct?
# INBOUND → TCP → Port 8001 → Allow → Any IPv4

Issue 2: "Timeout"

Checklist:

# 1. Firewall rule exists and is ALLOW (not DENY)?
# Check Hetzner Console → Firewalls → Rules → INBOUND

# 2. Correct IP address?
curl -4 ifconfig.me
# Use this IP in your test

# 3. No conflicting iptables rules?
iptables -L INPUT -n -v | grep 8001
# If you see DROP, remove it or use Hetzner firewall only

Issue 3: Port only listening on 127.0.0.1 (localhost)

Fix:

# Check docker-compose.yml
cat docker-compose.yml | grep ports

# Should be: "8001:8001"
# NOT: "127.0.0.1:8001:8001"

# If wrong, edit docker-compose.yml and restart:
docker-compose down
docker-compose up -d

Issue 4: Firewall rule exists but still blocked

Most Common Cause: Firewall not applied to server!

  1. Go to Hetzner Console → Firewalls
  2. Click on your firewall name
  3. Click "Resources" tab
  4. Check if your server is listed
  5. If NOT listed:
    • Click "Add Resource"
    • Select your server
    • Click "Add"
    • Wait 30 seconds
    • Try accessing again

Issue 5: Works locally but not from internet

Checklist:

# 1. Firewall INBOUND rule allows 0.0.0.0/0?
# Hetzner Console → Firewalls → Rules → INBOUND → Should allow "Any IPv4"

# 2. No deny-all rule before your allow rule?
# Rules are processed top-to-bottom

# 3. ISP not blocking port 8001?
# Try from different network (mobile data)

Issue 6: Firewall shows "Fully applied" but still blocked

This means firewall is configured but might not be attached to server:

  1. Go to Hetzner Console → Firewalls → Your firewall
  2. Click "Resources" tab
  3. If server is NOT listed → Add it (see Issue 4)
  4. If server IS listed but still blocked:
    • Remove server from firewall
    • Wait 10 seconds
    • Add server back to firewall
    • Wait 30 seconds
    • Test again

Issue 7: Can't ping server from outside (ICMP blocked)

This is NORMAL and EXPECTED! Many cloud providers (including Hetzner) block ICMP ping by default for security reasons. This does NOT affect HTTP/HTTPS access.

Don't use ping to test connectivity - use HTTP instead:

# Instead of: ping 95.217.14.18 (will timeout - this is normal!)

# Use HTTP test instead:
$serverIP = "95.217.14.18"
Invoke-RestMethod -Uri "http://$serverIP:8001/api/health"

# Or in browser:
# http://95.217.14.18:8001/api/health

If you really want to enable ping (not necessary):

  1. Go to Hetzner Console → Firewalls → Your firewall
  2. Click "Rules" tab → "INBOUND"
  3. Add rule:
    • Protocol: ICMP
    • Source: Any IPv4 (or your IP)
    • Action: Allow
  4. Apply to server

Note: HTTP/HTTPS access works fine even if ping is blocked. Ping is just a diagnostic tool - your API will work without it!

8.6: Set Up HTTPS/SSL (Secure Connection)

Current Status: Your API is accessible via HTTP (insecure). Let's secure it with HTTPS!

Option A: Nginx Reverse Proxy with Let's Encrypt (Recommended - Free SSL)

This sets up Nginx as a reverse proxy that handles SSL/TLS encryption:

Step 1: Install Nginx and Certbot

# Install Nginx and certbot
apk add nginx certbot certbot-nginx

# Start Nginx
service nginx start
rc-update add nginx boot

Step 2: Configure Firewall for HTTPS

  1. Go to Hetzner Console → Firewalls → Your firewall
  2. Click "Rules" tab → "INBOUND"
  3. Add rule:
    • Protocol: TCP
    • Port: 443 (HTTPS)
    • Source: Any IPv4
    • Action: Allow
  4. Also add port 80 (HTTP) for Let's Encrypt verification:
    • Protocol: TCP
    • Port: 80
    • Source: Any IPv4
    • Action: Allow

Step 3: Configure Nginx Reverse Proxy

# Create Nginx configuration
cat > /etc/nginx/http.d/timefold-api.conf << 'EOF'
server {
    listen 80;
    server_name YOUR_DOMAIN_OR_IP;  # Replace with your domain or IP
    
    # Redirect HTTP to HTTPS (after SSL is set up)
    # Uncomment after getting SSL certificate:
    # return 301 https://$server_name$request_uri;
    
    # For now, proxy to API (HTTP)
    location / {
        proxy_pass http://localhost:8001;
        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;
        # A solve answers on the same request and may run up to 600 s (config.timeoutSeconds, capped by
        # powersolver.limits.max-timeout-seconds). nginx's default is 60 s, which ends long solves with 504.
        proxy_read_timeout 660s;
        proxy_send_timeout 660s;
        send_timeout 660s;
        client_max_body_size 20m;   # large problems (500+ lessons) are several hundred KB
    }
}
EOF

# Replace YOUR_DOMAIN_OR_IP with your actual domain or IP
# If you have a domain: api.yourdomain.com
# If no domain: use your IP address (95.217.14.18)

# Edit the config file
vi /etc/nginx/http.d/timefold-api.conf
# Replace YOUR_DOMAIN_OR_IP with your domain or IP

# Test Nginx configuration
nginx -t

# Reload Nginx
service nginx reload

Step 4: Get SSL Certificate (If You Have a Domain)

If you have a domain name pointing to your server:

# Get SSL certificate from Let's Encrypt
certbot --nginx -d yourdomain.com -d www.yourdomain.com

# Or if using subdomain:
certbot --nginx -d api.yourdomain.com

# Follow the prompts:
# - Enter email address
# - Agree to terms
# - Choose whether to redirect HTTP to HTTPS (recommended: Yes)

If you DON'T have a domain (IP only):

Unfortunately, Let's Encrypt doesn't issue certificates for IP addresses. Options:

  1. Use a domain name (recommended):
    • Buy a domain (e.g., from Namecheap, GoDaddy)
    • Point A record to your server IP (95.217.14.18)
    • Then use certbot as above
  1. Use self-signed certificate (for testing, browsers will show warning):
# Generate self-signed certificate
mkdir -p /etc/nginx/ssl
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout /etc/nginx/ssl/timefold-api.key \
  -out /etc/nginx/ssl/timefold-api.crt \
  -subj "/CN=95.217.14.18"

# Update Nginx config for HTTPS
cat > /etc/nginx/http.d/timefold-api-ssl.conf << 'EOF'
server {
    listen 443 ssl;
    server_name 95.217.14.18;  # Your IP
    
    ssl_certificate /etc/nginx/ssl/timefold-api.crt;
    ssl_certificate_key /etc/nginx/ssl/timefold-api.key;
    
    location / {
        proxy_pass http://localhost:8001;
        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;
        # A solve answers on the same request and may run up to 600 s (config.timeoutSeconds, capped by
        # powersolver.limits.max-timeout-seconds). nginx's default is 60 s, which ends long solves with 504.
        proxy_read_timeout 660s;
        proxy_send_timeout 660s;
        send_timeout 660s;
        client_max_body_size 20m;   # large problems (500+ lessons) are several hundred KB
    }
}

server {
    listen 80;
    server_name 95.217.14.18;
    return 301 https://$server_name$request_uri;
}
EOF

# Test and reload
nginx -t
service nginx reload

Step 5: Update Firewall for Nginx

After setting up Nginx, you can:

  • Keep port 8001 open (direct access) OR
  • Close port 8001 and only allow 80/443 (more secure)
# Option: Close port 8001 in firewall (only allow 80/443)
# Go to Hetzner Console → Firewalls → Remove port 8001 rule
# Keep only: 22 (SSH), 80 (HTTP), 443 (HTTPS)

Step 6: Test HTTPS Access

# From PowerShell (if using domain with Let's Encrypt):
Invoke-RestMethod -Uri "https://api.yourdomain.com/api/health"

# Or with self-signed cert (will show warning, click "Advanced" → "Proceed"):
Invoke-RestMethod -Uri "https://95.217.14.18/api/health" -SkipCertificateCheck

Option B: Use Hetzner Load Balancer (Easier but Costs Extra)

  1. Go to Hetzner Console → Load Balancers
  2. Create new Load Balancer
  3. Configure:
    • Listen on port 443 (HTTPS)
    • Forward to your server:8001
    • Enable SSL/TLS termination
    • Upload SSL certificate or use Let's Encrypt
  4. Point your domain to Load Balancer IP

Option C: Use Cloudflare (Free SSL, No Server Config)

  1. Sign up for Cloudflare (free)
  2. Add your domain to Cloudflare
  3. Point domain to your server IP
  4. Enable "SSL/TLS" → "Full" mode
  5. Cloudflare provides free SSL automatically

8.7: Security Considerations

⚠️ Important Security Notes:

  1. HTTPS Setup:
    • ✅ Recommended: Use Option A (Nginx + Let's Encrypt) if you have a domain
    • ✅ Alternative: Use Cloudflare for free SSL without server config
    • ⚠️ Current: HTTP is insecure - data is not encrypted
  1. Restrict Access (Optional but Recommended):
# Instead of allowing 0.0.0.0/0, restrict to specific IPs
# In Hetzner Firewall, allow 8001/tcp only from your office IP
# Or use VPN to access the API
  1. Monitor Access:
# Check who's accessing your API
docker-compose logs timefold-api | grep -i "GET\|POST"

# Monitor connections
netstat -an | grep 8001

# Check Nginx access logs (if using Nginx)
tail -f /var/log/nginx/access.log
  1. Rate Limiting (Future Enhancement):
    • Consider adding rate limiting at firewall or application level
    • Use Nginx as reverse proxy with rate limiting

Part 5: Firewall Configuration

Step 1: Configure Alpine Firewall (if needed)

Alpine uses iptables for firewall management. Alpine doesn't include ufw by default:

# Install iptables (usually already installed)
apk add iptables

# Set default policies (deny incoming, allow outgoing)
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT

# Allow loopback
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT

# Allow established connections
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# Allow SSH (port 22) - IMPORTANT: Do this first!
iptables -A INPUT -p tcp --dport 22 -j ACCEPT

# Allow HTTP/HTTPS for Nginx reverse proxy
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT

# Allow PowerSolver API port (if accessing directly, otherwise only localhost)
# If using Nginx reverse proxy, you can restrict this to localhost only:
# iptables -A INPUT -p tcp -s 127.0.0.1 --dport 8001 -j ACCEPT
# Otherwise, allow public access:
iptables -A INPUT -p tcp --dport 8001 -j ACCEPT

# Port 8000 is not needed on Hetzner server

# Allow Redis port (only if needed externally)
# iptables -A INPUT -p tcp --dport 6379 -j ACCEPT

# Save rules for Alpine Linux
apk add iptables-openrc
mkdir -p /etc/iptables
iptables-save > /etc/iptables/rules.v4

# Enable iptables service to restore rules on boot
rc-update add iptables boot

⚠️ Warning: Be careful with firewall rules! Make sure SSH (port 22) is allowed before setting default DROP policy, or you may lock yourself out.

⚠️ IMPORTANT: Configure INBOUND Rules

  1. Go to Hetzner Cloud Console → Firewalls
  2. Create a new firewall or edit existing one
  3. Click on "Inbound Rules" tab (or "Rules" → "Inbound")
  4. Add INBOUND rules (these allow traffic coming INTO your server):
    • SSH (22/tcp) - Allow from your IP (required!)
    • PowerSolver API (8001/tcp) - Allow from your IP or 0.0.0.0/0 (public)
    • HTTP (80/tcp) - Optional (if using reverse proxy or web server)
    • HTTPS (443/tcp) - Optional (if using reverse proxy with SSL)
  1. Click "Apply to Resources" → Select your server
  2. Done!

Rule Direction Explanation:

  • INBOUND = Traffic coming FROM internet TO your server (what you need!)
  • OUTBOUND = Traffic going FROM your server TO internet (usually allowed by default)

Part 6: Accessing the API

📌 Note: Before accessing from the internet, complete Step 8: Expose Container to Internet above to configure firewall and ensure the container is accessible.

Local Access (from server)

# Health check
curl http://localhost:8001/api/health

# API documentation
curl http://localhost:8001/v3/api-docs

# Swagger UI (if accessible)
curl http://localhost:8001/swagger-ui.html

Remote Access (from Internet)

⚠️ IMPORTANT: API must be accessed via HTTPS for Excel Online compatibility.

From Windows PowerShell:

# Using API subdomain (recommended)
Invoke-RestMethod -Uri "https://api.planningpowertools.com/api/health"

# Or if using IP with self-signed certificate (not recommended):
# Invoke-RestMethod -Uri "https://YOUR_SERVER_IP/api/health" -SkipCertificateCheck

From Command Line (curl):

# Using API subdomain (recommended)
curl https://api.planningpowertools.com/api/health

# Or if using IP with self-signed certificate:
# curl -k https://YOUR_SERVER_IP/api/health

From Web Browser: Navigate to: https://api.planningpowertools.com/api/health

Service URLs

⚠️ IMPORTANT: All API access must use HTTPS for Excel Online compatibility.

Once deployed and configured with HTTPS, access services at:

  • API Base URL: https://api.planningpowertools.com/api
  • Health Check: https://api.planningpowertools.com/api/health
  • Swagger UI: https://api.planningpowertools.com/swagger-ui.html
  • API Docs: https://api.planningpowertools.com/v3/api-docs
  • Redis: Not exposed externally (internal Docker network only)

Note: The API runs on port 8001 internally but is accessed via HTTPS on port 443 through the Nginx reverse proxy.


Part 7: Managing Services

View Logs

# All services
docker-compose logs -f

# Specific service
docker-compose logs -f timefold-api
docker-compose logs -f redis

# Last 100 lines
docker-compose logs --tail 100 timefold-api

Stop Services

# Stop all services
docker-compose down

# Stop but keep volumes
docker-compose stop

Restart Services

# Restart all services
docker-compose restart

# Restart specific service
docker-compose restart timefold-api

Update Services

# Pull latest image (if using registry)
docker pull powersolver-timefold-api:latest

# Rebuild and restart
docker-compose up -d --build

# Or restart with new image
docker-compose down
docker-compose up -d

Check Resource Usage

# Container stats
docker stats

# Disk usage
docker system df

# Clean up unused resources
docker system prune -a

Back Up the Stored Plans

PowerSolver keeps every plan made in the wizard in /data/powersolver.db (SQLite) on the powersolver-data volume. Updating the container keeps it. Back it up with SQLite's online backup (a plain file copy of a live database can be inconsistent):

mkdir -p /root/backups
docker run --rm -v powersolver-data:/data -v /root/backups:/backup alpine sh -c \
  "apk add --no-cache sqlite >/dev/null && sqlite3 /data/powersolver.db \".backup '/backup/powersolver-$(date +%Y%m%d-%H%M%S).db'\""

# restore (stop the API first)
docker-compose stop timefold-api
docker run --rm -v powersolver-data:/data -v /root/backups:/backup alpine cp /backup/powersolver-YYYYMMDD-HHMMSS.db /data/powersolver.db
docker-compose start timefold-api

No sign-in check on the API yet. Anyone who can reach https://api.planningpowertools.com can list, change and remove stored plans. Until the product has its own authentication, keep regular backups.

Part 8: Troubleshooting

Problem: Cannot Access Web Console

Solution:

  • Wait a few minutes after reboot
  • Try refreshing the console
  • Check if server is powered on in Hetzner console

Problem: Alpine Installation Fails

Solutions:

# Check disk space
df -h

# Check network connectivity
ping 8.8.8.8
# Or install full ping package:
apk add iputils-ping
ping -c 3 8.8.8.8

# Try installation again
setup-alpine

Problem: Server Keeps Rebooting from ISO (Boot Loop)

Symptoms: After installation completes, the server keeps rebooting and shows the Alpine login prompt (no password required), indicating it's booting from the ISO instead of the installed system.

Cause: The ISO image is still mounted, so the server prioritizes booting from the ISO over the installed system.

Solution:

  1. Unmount the ISO immediately:
    • Go to Hetzner Cloud Console
    • Navigate to your server → "ISO Images" tab
    • Click "Unmount" or "Eject" next to the Alpine ISO image
    • Wait for confirmation
  1. Power cycle the server:
    • Go to "Power" tab in Hetzner console
    • Toggle power OFF, wait 10 seconds, then toggle ON
    • Or click "Reboot" button
  1. Verify it boots from disk:
    • After reboot, you should see localhost login: prompt
    • Try logging in with root and your installation password
    • If it asks for a password, you're booting from the installed system! ✅
    • If no password is required, the ISO might still be mounted - check again

Prevention: Always unmount the ISO before rebooting after installation (see Step 6 in Part 1).

Problem: Docker Package Not Found ("no such package: docker")

Symptoms: When running apk add docker, you get "ERROR: unable to select packages: docker (no such package)".

Cause: Docker Engine is not available in Alpine Linux's default stable repositories. You need to use the edge repository or Docker's installation script.

Solutions:

Option 1: Use Alpine Edge Repository (Recommended)

# Add edge repository
echo "http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories
apk update
apk add docker docker-cli docker-compose

Option 2: Use Docker's Official Installation Script

curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
apk add docker-compose

Option 3: Install from Edge and Remove Repository (More Secure)

echo "http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories
apk update
apk add docker docker-cli docker-compose
sed -i '/edge\/community/d' /etc/apk/repositories
apk update

Problem: Cannot Connect to Docker Daemon ("Cannot connect to the Docker daemon at unix:///var/run/docker.sock")

Symptoms:

  • ERROR: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
  • Docker CLI is installed (docker --version works) but docker ps fails

Cause: Docker daemon service is not running or failed to start.

Solutions:

Step 1: Check Docker Service Status

# Check if Docker service is running
service docker status

# Check detailed status
rc-service docker status

# Check if Docker daemon process exists
ps aux | grep dockerd

Step 2: Start Docker Service

# Start Docker service
service docker start

# Wait a few seconds
sleep 5

# Verify it's running
service docker status

# Test Docker connection
docker ps

Step 3: Check for Errors

# Check Docker logs for errors
tail -50 /var/log/docker.log

# Or check service logs
rc-service docker status

# Check system logs
dmesg | grep -i docker

Step 4: Enable Docker on Boot

# Make sure Docker starts on boot
rc-update add docker boot

# Verify it's enabled
rc-update show docker

Step 5: Check Docker Socket

# Check if Docker socket exists and has correct permissions
ls -la /var/run/docker.sock

# If socket doesn't exist or has wrong permissions:
# Restart Docker service
service docker restart

# Check socket again
ls -la /var/run/docker.sock
# Should show: srw-rw---- 1 root docker (or similar)

Step 6: Diagnose Why Docker Daemon Won't Start

If dockerd exists but the socket file is missing, the daemon is failing to start. Run these diagnostics:

# Check if Docker daemon process is running
ps aux | grep dockerd

# Check Docker service status in detail
rc-service docker status

# Check Docker logs for specific errors
tail -100 /var/log/docker.log

# Check system logs for Docker errors
dmesg | tail -50 | grep -i docker

# Try starting Docker daemon manually to see error messages
dockerd 2>&1 &
# Wait a moment, then check if socket was created
sleep 3
ls -la /var/run/docker.sock
# Kill the manual process
pkill dockerd

# Check if required kernel modules are loaded
lsmod | grep overlay
lsmod | grep br_netfilter

# Check cgroups v2 (Alpine/Docker requirement)
mount | grep cgroup
cat /proc/filesystems | grep cgroup

# Check if cgroup2 is mounted
stat -fc %T /sys/fs/cgroup

# Check Docker dependencies
apk info docker | grep -A 20 "Depends on"

# Check if /var/run directory exists and is writable
ls -ld /var/run
touch /var/run/test && rm /var/run/test && echo "Directory is writable"

Common Issues and Fixes:

Issue 1: Missing cgroups support

# Check cgroups
mount | grep cgroup

# If cgroups are missing, Docker won't work
# Alpine should have them, but verify:
apk add cgroupfs-mount
cgroupfs-mount

Issue 2: Missing kernel modules

# Load required modules
modprobe overlay
modprobe br_netfilter

# Make them persistent
echo overlay >> /etc/modules-load.d/docker.conf
echo br_netfilter >> /etc/modules-load.d/docker.conf

Issue 3: Docker daemon configuration error (JSON syntax error)

Symptoms: Log shows unable to configure the Docker daemon with file /etc/docker/daemon.json: invalid character after top-level value

Fix:

# Check the current daemon.json file
cat /etc/docker/daemon.json

# Backup the broken file
mv /etc/docker/daemon.json /etc/docker/daemon.json.bak

# Create a new, valid daemon.json (or remove it to use defaults)
cat > /etc/docker/daemon.json << 'EOF'
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  },
  "storage-driver": "overlay2"
}
EOF

# Validate JSON syntax (if jq is installed)
apk add jq
jq . /etc/docker/daemon.json

# Or simply remove the file to use Docker defaults
# rm /etc/docker/daemon.json

# Now start Docker
service docker start
sleep 5
docker ps

Common JSON errors to check:

  • Trailing commas after last item
  • Missing commas between items
  • Unclosed brackets or braces
  • Comments (JSON doesn't support comments)
  • Extra characters after closing brace

Issue 4: Docker daemon failing silently

# Run dockerd with verbose logging
dockerd --log-level=debug > /tmp/dockerd.log 2>&1 &
sleep 5
cat /tmp/dockerd.log
pkill dockerd

Issue 5: Check Alpine-specific Docker issues

# Verify Docker was installed from edge repository correctly
apk info docker

# Check if Docker service script exists
ls -la /etc/init.d/docker
cat /etc/init.d/docker | head -20

Step 7: Reinstall Docker (Last Resort)

# Stop Docker
service docker stop

# Remove Docker
apk del docker docker-cli docker-compose

# Reinstall Docker
echo "http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories
apk update
apk add docker docker-cli docker-compose

# Start Docker
service docker start
rc-update add docker boot

# Verify
docker ps

Problem: Docker Won't Start

Solutions:

# Check Docker service status
service docker status

# Check Docker logs (Alpine uses different log location)
tail -f /var/log/docker.log
# Or check service logs
rc-service docker status

# Restart Docker
service docker restart

# Check if Docker is enabled
rc-update show docker

# Check Docker daemon is running
ps aux | grep docker

Problem: Cannot Build Docker Image

Solutions:

# Check disk space
df -h

# Clean Docker cache
docker system prune -a

# Check Dockerfile syntax
docker build --no-cache -t test-image .

# Increase Docker memory (if needed)
# Edit /etc/docker/daemon.json

Problem: API Not Responding

Solutions:

# Check if container is running
docker ps

# Check container logs
docker-compose logs timefold-api

# Check if port is accessible
netstat -tuln | grep 8001

# Test from inside container
docker exec powersolver-api curl http://localhost:8001/api/health

# Restart container
docker-compose restart timefold-api

Problem: Out of Memory

Solutions:

# Check memory usage
free -h
docker stats

# Reduce Java heap size in docker-compose.yml
# Change JAVA_OPTS to: -Xmx1024m -Xms256m

# Or upgrade your VPS plan

Problem: Ping Command Errors ("invalid number")

Symptoms: When running ping -c google.com or ping -c 8.8.8.8, you get "ping: invalid number" error.

Cause: Alpine Linux uses busybox's minimal ping implementation which has different syntax requirements, or the full ping package isn't installed.

Solutions:

Option 1: Use ping without -c flag:

ping google.com
# Press Ctrl+C after a few seconds to stop

Option 2: Install full ping package (recommended):

apk add iputils-ping
ping -c 3 google.com

Option 3: Use wget or curl to test connectivity:

wget -O- http://google.com
# or
curl -I http://google.com

Option 4: Check network interface directly:

ip addr show
ip route show

Problem: GitHub Authentication Failed ("Password authentication is not supported")

Symptoms: When cloning from GitHub, you get:

  • remote: Invalid username or token. Password authentication is not supported for Git operations.
  • fatal: Authentication failed

Cause: GitHub deprecated password authentication in August 2021. You must use Personal Access Tokens (PAT) or SSH keys.

Solutions:

Option 1: Use Personal Access Token (Quick Fix)

# Create a PAT on GitHub (see Method A above)
# Then clone with token in URL:
git clone https://YOUR_TOKEN@github.com/arcontechnologies/PowerPlanningTools.git

# Or use credential helper:
git config --global credential.helper store
git clone https://github.com/arcontechnologies/PowerPlanningTools.git
# When prompted:
# Username: your-github-username
# Password: paste-your-PAT-token (NOT your GitHub password!)

Option 2: Use SSH Keys (More Secure)

# Generate SSH key
ssh-keygen -t ed25519 -C "your-email@example.com" -f ~/.ssh/github_key

# Display and copy public key
cat ~/.ssh/github_key.pub

# Add key to GitHub (Settings → SSH and GPG keys → New SSH key)
# Then clone using SSH:
git clone git@github.com:arcontechnologies/PowerPlanningTools.git

Option 3: Clear Cached Credentials

# Remove cached credentials
git config --global --unset credential.helper
rm -rf ~/.git-credentials

# Try cloning again with PAT

Option 4: Check Repository Visibility

# If repository is private, ensure your PAT has 'repo' scope
# If repository can be public, make it public to clone without auth

Problem: Cannot Connect via SSH

Solutions:

# Check SSH service
service sshd status

# Start SSH if not running
service sshd start

# Enable SSH on boot
rc-update add sshd boot

# Check SSH configuration
cat /etc/ssh/sshd_config

Part 9: Security Best Practices

1. Change Default SSH Port (Optional)

# Edit SSH config
vi /etc/ssh/sshd_config

# Change Port 22 to something else (e.g., 2222)
# Uncomment and modify: Port 2222

# Restart SSH
service sshd restart

2. Disable Root Login via SSH

# Edit SSH config
vi /etc/ssh/sshd_config

# Set: PermitRootLogin no

# Create a new user
adduser -D -s /bin/sh yourusername
passwd yourusername

# Add to sudo group (if available)
adduser yourusername wheel

# Restart SSH
service sshd restart

3. Set Up Fail2Ban

# Install fail2ban
apk add fail2ban

# Configure fail2ban
vi /etc/fail2ban/jail.local

# Start fail2ban
service fail2ban start
rc-update add fail2ban boot

4. Regular Updates

# Update system packages regularly
apk update && apk upgrade

# Update Docker images
docker-compose pull
docker-compose up -d

5. Backup Strategy

# Backup Docker volumes
docker run --rm -v powersolver-redis:/data -v $(pwd):/backup alpine tar czf /backup/redis-backup.tar.gz /data

# Backup configuration files
tar czf config-backup.tar.gz /root/timefold-api/

Part 10: Monitoring and Maintenance

Set Up Log Rotation

# Install logrotate
apk add logrotate

# Configure Docker log rotation (already in daemon.json)
# Max size: 10MB, Max files: 3

Monitor Disk Space

# Check disk usage
df -h

# Check Docker disk usage
docker system df

# Clean up unused images/containers
docker system prune -a

Set Up Health Checks

Create a simple health check script:

cat > /root/health-check.sh << 'EOF'
#!/bin/sh
if curl -f http://localhost:8001/api/health > /dev/null 2>&1; then
    echo "API is healthy"
    exit 0
else
    echo "API is down, restarting..."
    docker-compose restart timefold-api
    exit 1
fi
EOF

chmod +x /root/health-check.sh

# Add to crontab (check every 5 minutes)
crontab -e
# Add: */5 * * * * /root/health-check.sh

Quick Reference

Essential Commands

# System
apk update && apk upgrade          # Update packages
service docker start                # Start Docker
service docker status              # Check Docker status
rc-update add docker boot          # Enable Docker on boot

# Docker
docker ps                          # List running containers
docker images                      # List images
docker-compose ps                  # List compose services
docker-compose logs -f             # Follow logs
docker-compose up -d               # Start services
docker-compose down                # Stop services
docker-compose restart             # Restart services

# API
# Local access (HTTP is fine for localhost)
curl http://localhost:8001/api/health                    # Health check
curl http://localhost:8001/v3/api-docs                   # API docs

# External access (HTTPS required)
curl https://api.planningpowertools.com/api/health        # Health check via HTTPS
curl https://api.planningpowertools.com/v3/api-docs      # API docs via HTTPS
docker-compose logs -f timefold-api                      # API logs

File Locations

/root/timefold-api/                # Application directory
/root/timefold-api/docker-compose.yml  # Compose config
/etc/docker/daemon.json            # Docker configuration
/var/lib/docker/                   # Docker data directory

Summary

You have:

  1. ✅ Installed Alpine Linux on a Hetzner VPS
  2. ✅ Set up Docker and Docker Compose
  3. ✅ Deployed the PowerSolver API (container on port 8001, behind Nginx with HTTPS on api.planningpowertools.com; proxy_read_timeout 660s so that a 600-second plan can finish)
  4. ✅ Set up the firewall (22, 80, 443)
  5. ✅ Verified https://api.planningpowertools.com/api/health and /api/version

Next: the wizard is a static container (see ADMIN_GUIDE.md); day-to-day operations, limits and troubleshooting are in the Administrator Guide.


Additional Resources


Part 11: CORS Configuration Guide

⚠️ CRITICAL: CORS (Cross-Origin Resource Sharing) configuration is the most common source of "Failed to fetch" errors when deploying the PowerSolver API. This section provides the complete, tested configuration.

Understanding CORS

CORS errors occur when:

  1. Your frontend (e.g., https://planning.planningpowertools.com) makes requests to a different domain (e.g., https://api.planningpowertools.com)
  2. The API doesn't include the proper Access-Control-Allow-Origin headers
  3. The browser blocks the request for security reasons

Common CORS Error Messages:

  • "Failed to fetch"
  • "Access to fetch at 'X' from origin 'Y' has been blocked by CORS policy"
  • "No 'Access-Control-Allow-Origin' header is present on the requested resource"
  • "Response to preflight request doesn't pass access control check"

Complete CORS Setup

CORS must be configured in THREE places:

  1. Spring Boot Java Configuration (CorsConfig.java)
  2. Spring Boot Properties (application.properties)
  3. Nginx Reverse Proxy (api.conf)

Spring Boot Configuration

File 1: CorsConfig.java

Location: timefold-api/src/main/java/com/powersolver/config/CorsConfig.java

package com.powersolver.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.Arrays;

/**
 * Global CORS Configuration
 * 
 * IMPORTANT: Using allowedOriginPatterns instead of allowedOrigins to support
 * both HTTP and HTTPS for the same domain and to work with allowCredentials(true).
 */
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOriginPatterns(
                    // Main website + planning apps
                    "https://www.planningpowertools.com",
                    "https://planningpowertools.com",
                    "http://planningpowertools.com",
                    "https://planning.planningpowertools.com",
                    "https://admin-planning.planningpowertools.com",
                    "https://test-planning.planningpowertools.com",
                    // API subdomain (Swagger UI)
                    "https://api.planningpowertools.com",
                    "http://api.planningpowertools.com",
                    // Local development
                    "https://localhost:*",
                    "http://localhost:*"
                )
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD")
                .allowedHeaders("*")
                .exposedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }

    /**
     * Additional CORS filter as fallback
     */
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration configuration = new CorsConfiguration();
        
        configuration.setAllowedOriginPatterns(Arrays.asList(
            "https://www.planningpowertools.com",
            "https://planningpowertools.com",
            "http://planningpowertools.com",
            "https://planning.planningpowertools.com",
            "https://admin-planning.planningpowertools.com",
            "https://test-planning.planningpowertools.com",
            "https://api.planningpowertools.com",
            "http://api.planningpowertools.com",
            "https://localhost:*",
            "http://localhost:*"
        ));
        
        configuration.setAllowedMethods(Arrays.asList(
            "GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"
        ));
        
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setExposedHeaders(Arrays.asList("*"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", configuration);
        
        return new CorsFilter(source);
    }
}

⚠️ CRITICAL: Add ALL your frontend domains! If you have a frontend at https://planning.planningpowertools.com, it MUST be in this list.

File 2: application.properties

Location: timefold-api/src/main/resources/application.properties

spring.application.name=powersolver-timefold-api
server.port=8001

# Trust proxy headers from Nginx (CRITICAL for HTTPS detection)
server.forward-headers-strategy=framework
server.use-forward-headers=true

# Swagger/OpenAPI Configuration
springdoc.api-docs.path=/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.operations-sorter=method
springdoc.swagger-ui.tags-sorter=alpha
springdoc.swagger-ui.default-models-expand-depth=1
springdoc.swagger-ui.default-model-expand-depth=1

# Disable auto-generated server URL (prevents HTTP URL in Swagger)
springdoc.swagger-ui.disable-swagger-default-url=true

# CORS Configuration (backup - Java config takes precedence)
spring.web.cors.allowed-origins=https://www.planningpowertools.com,https://planningpowertools.com,http://planningpowertools.com,https://planning.planningpowertools.com,https://admin-planning.planningpowertools.com,https://test-planning.planningpowertools.com,https://api.planningpowertools.com,http://api.planningpowertools.com,https://localhost:3000,http://localhost:3000,http://localhost:8001,https://localhost:8001
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS,PATCH,HEAD
spring.web.cors.allowed-headers=*
spring.web.cors.exposed-headers=*
spring.web.cors.allow-credentials=true
spring.web.cors.max-age=3600

File 3: OpenApiConfig.java (For Swagger UI)

Location: timefold-api/src/main/java/com/powersolver/config/OpenApiConfig.java

package com.powersolver.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Arrays;

/**
 * OpenAPI/Swagger configuration
 * CRITICAL: Define HTTPS server FIRST to prevent mixed content errors
 */
@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .servers(Arrays.asList(
                new Server()
                    .url("https://api.planningpowertools.com")
                    .description("Production Server (HTTPS)"),
                new Server()
                    .url("http://localhost:8001")
                    .description("Local Development (HTTP)")
            ))
            .info(new Info()
                .title("Planning Power Tools v1.0")
                .version("1.0.0")
                .description("Dynamic planning problem solver API powered by Timefold.")
                .contact(new Contact()
                    .name("Planning Power Tools")
                    .url("https://planningpowertools.com")));
    }
}

Nginx Configuration

Location on Hetzner Server: /etc/nginx/http.d/api.conf

# HTTP Server Block - Redirect to HTTPS
server {
    listen 80;
    server_name api.planningpowertools.com;

    # Let's Encrypt certificate verification
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # Redirect everything else to HTTPS
    location / {
        return 301 https://$server_name$request_uri;
    }
}

# HTTPS Server Block
server {
    listen 443 ssl;
    http2 on;
    server_name api.planningpowertools.com;

    # SSL Certificate (Let's Encrypt)
    ssl_certificate /etc/letsencrypt/live/api.planningpowertools.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.planningpowertools.com/privkey.pem;

    # SSL Settings
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;
    ssl_session_timeout 10m;

    # Security Headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Proxy to Spring Boot API
    location / {
        proxy_pass http://localhost:8001;
        proxy_http_version 1.1;
        
        # CRITICAL: Forward headers for Spring Boot to detect HTTPS
        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;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;

        # Timeouts for long-running solver requests: a solve may run up to 600 s (the engine's
        # max-timeout-seconds) and answers on the same request, so allow a margin above it.
        proxy_connect_timeout 60s;
        proxy_send_timeout 660s;
        proxy_read_timeout 660s;
        send_timeout 660s;
        client_max_body_size 20m;   # large problems (500+ lessons) are several hundred KB

        # Pass CORS headers from Spring Boot (don't duplicate!)
        proxy_pass_header Access-Control-Allow-Origin;
        proxy_pass_header Access-Control-Allow-Methods;
        proxy_pass_header Access-Control-Allow-Headers;
        proxy_pass_header Access-Control-Expose-Headers;
        proxy_pass_header Access-Control-Allow-Credentials;
    }
}

Apply Nginx changes:

nginx -t && service nginx reload

Testing CORS

Test 1: Preflight OPTIONS Request

# From Hetzner server (bypass Nginx)
curl -X OPTIONS \
  -H "Origin: https://planning.planningpowertools.com" \
  -H "Access-Control-Request-Method: POST" \
  -v http://localhost:8001/api/health

# Should see these headers in response:
# Access-Control-Allow-Origin: https://planning.planningpowertools.com
# Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS, PATCH, HEAD
# Access-Control-Allow-Credentials: true

Test 2: Through Nginx (HTTPS)

# From any machine
curl -X OPTIONS \
  -H "Origin: https://planning.planningpowertools.com" \
  -H "Access-Control-Request-Method: POST" \
  -v https://api.planningpowertools.com/api/health

Test 3: From PowerShell

$headers = @{
    "Origin" = "https://planning.planningpowertools.com"
    "Access-Control-Request-Method" = "POST"
}
$response = Invoke-WebRequest -Uri "https://api.planningpowertools.com/api/health" -Headers $headers -Method OPTIONS
$response.Headers

# Should show Access-Control-Allow-Origin header

Test 4: Check OpenAPI Servers

curl -s https://api.planningpowertools.com/v3/api-docs | grep -A10 '"servers"'

# Should show HTTPS server first:
# "servers": [
#   { "url": "https://api.planningpowertools.com", ... },
#   { "url": "http://localhost:8001", ... }
# ]

CORS Troubleshooting

Problem: "Failed to fetch" or "No Access-Control-Allow-Origin header"

Cause: Origin not in allowed list or CORS not configured.

Fix:

  1. Check if your frontend domain is in CorsConfig.java:
"https://YOUR-FRONTEND-DOMAIN.com",
"http://YOUR-FRONTEND-DOMAIN.com",
  1. Rebuild and redeploy Docker image:
# On local machine
cd D:\PowerSolver\timefold-api
docker build -t powersolver-timefold-api:latest .
docker save powersolver-timefold-api:latest -o powersolver-timefold-api.tar
scp powersolver-timefold-api.tar root@YOUR_SERVER_IP:/root/
# On Hetzner server
cd /root
docker-compose down
docker load -i powersolver-timefold-api.tar
docker-compose up -d

Problem: Swagger UI shows "http://" instead of "https://"

Cause: SpringDoc auto-generates HTTP URL from internal request.

Fix:

  1. Ensure application.properties has:
springdoc.swagger-ui.disable-swagger-default-url=true
server.forward-headers-strategy=framework
  1. Ensure OpenApiConfig.java lists HTTPS server first.
  1. Ensure Nginx forwards X-Forwarded-Proto:
proxy_set_header X-Forwarded-Proto $scheme;

Problem: CORS works locally but not in production

Cause: Usually Nginx configuration issue.

Fix:

  1. Verify Nginx passes CORS headers:
proxy_pass_header Access-Control-Allow-Origin;
  1. Verify HTTP redirects to HTTPS:
location / {
    return 301 https://$server_name$request_uri;
}
  1. Test directly against container (bypass Nginx):
# On server
curl -X OPTIONS -H "Origin: https://planning.planningpowertools.com" \
  http://localhost:8001/api/health -v

Problem: "Response to preflight request doesn't pass access control check"

Cause: OPTIONS request not handled correctly.

Fix:

  1. Ensure OPTIONS is in allowed methods:
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD")
  1. Check Spring Boot is handling OPTIONS (not Nginx):
    • Don't add CORS headers in Nginx
    • Let Spring Boot handle all CORS logic

Problem: Mixed Content errors in Swagger UI

Cause: HTTPS page making HTTP requests.

Fix:

  1. Ensure OpenApiConfig.java has HTTPS server first
  2. Access Swagger via HTTPS: https://api.planningpowertools.com/swagger-ui/index.html
  3. Clear browser cache (Ctrl+Shift+Delete)

Quick CORS Checklist

Before deploying, verify:

  • ☐ CorsConfig.java includes ALL frontend domains
  • ☐ application.properties has server.forward-headers-strategy=framework
  • ☐ application.properties has springdoc.swagger-ui.disable-swagger-default-url=true
  • ☐ OpenApiConfig.java lists HTTPS server first
  • ☐ Nginx has proxy_set_header X-Forwarded-Proto $scheme;
  • ☐ Nginx HTTP block redirects to HTTPS
  • ☐ Docker image was rebuilt after changes
  • ☐ Browser cache cleared after deployment

Adding New Frontend Domains

When adding a new frontend (e.g., https://newapp.example.com):

  1. Update CorsConfig.java - Add to BOTH places:
"https://newapp.example.com",
"http://newapp.example.com",
  1. Update application.properties:
spring.web.cors.allowed-origins=...,https://newapp.example.com,http://newapp.example.com
  1. Rebuild and redeploy the Docker image.
  1. Clear browser cache and test.

Document Version: 1.3 Last Updated: December 2025 Platform: Hetzner VPS + Alpine Linux + Docker + Spring Boot Tested On: CPX22 (4GB RAM, 2 vCPU) Changes in 1.3: Added comprehensive CORS Configuration Guide (Part 13)