Skip to main content

Deployment on a VPS

Complete Code
The end result of the code developed in this document can be found in the GitHub monorepo springboot-demo-projects, under the tag vps-coolify-deployment.

Deploying to your own VPS with Coolify gives you more control and avoids the cold start limitations of free PaaS tiers. However, this document won't go too deep about how to set up a VPS + domain + Coolify instance itself.

For that, I recommend you check:

Once you have your Coolify instance up and running, let's deploy your Spring Boot application.

Why a VPS?

There are plenty of ways to host a web application in 2026. Managed Kubernetes clusters, serverless functions, PaaS providers with generous free tiers, you name it. Here's a rough map of the landscape:

Scroll to zoom • Drag corner to resize
Where do you see your project in the next few years?
                ^
                |
I need global   |  HYPER-SCALERS            :  AWS WRAPPERS
reach & five 9  |  - massive control        :  - "I vibecoded something and
reliability &   |  - overwhelming service   :    I want to share it"
availability    |    list                   :  - dashboards >> code
                |  - requires expert        :  - what is a container?
                |    knowledge              :
                |                           :
                |  [AWS] [Azure] [GCloud]   :  [Vercel] [Sevalla]
                |                           :  [Railway] [Render]
                |...........................:............................
                |                           :
                |  DEVELOPER CLOUD          :  TRADITIONAL HOSTS
                |  - high control over OS   :  - easy setup
                |  - simple pricing         :  - high support
                |  - focused on core VMs    :  - less configuration
                |                           :    flexibility
                |                           :
                |  [DigitalOcean]           :  [Hostinger]
                |  [Hetzner]                :  [DreamHost] [A2 Hosting]
I don't think   |                           :
I'll hit 100k   |                           :
active users    +---------------------------------------------------------->
anytime soon
                I'm proficient in   <----------->   I'd prefer to not
                sudo su apt update                  mess much with that

                How comfortable are you with Linux commands,
                        security patching, etc?

For this project, we're going with a VPS in the "Developer Cloud" quadrant. The reason is simple: it's the easiest to explain, and the easiest to reason about. You get a Linux box, you SSH into it, you run your containers. There's no abstraction layer hiding what's happening.

That simplicity comes with tradeoffs. By choosing a single VPS, you're giving up things that real-world production systems often need:

  • Load balancing across multiple instances
  • Rolling deployments with zero downtime
  • Auto-scaling when traffic spikes
  • Multi-region redundancy for global availability
  • Managed databases with automatic backups and failover

You don't need any of that right now. You're learning how deployment works, not architecting for millions of users. A single VPS with Docker Compose is more than enough, and when the time comes to scale, you'll understand the fundamentals well enough to know what you're scaling away from.

Architecture Overview

Here's exactly what happens when you push code to the main branch:

Scroll to zoom • Drag corner to resize

The key insight here is that Coolify only deploys if GitHub Actions passes. This prevents broken code from reaching production. GitHub Actions handles the heavy lifting of compiling and testing across all three language implementations, while Coolify focuses purely on the deployment orchestration.

Alternative: Docker Registry Approach

The architecture above has Coolify building Docker images on the VPS itself. There's another common pattern where GitHub Actions builds the images, pushes them to a container registry (like GitHub Container Registry, Docker Hub, or AWS ECR), and Coolify just pulls the pre-built images:

Scroll to zoom • Drag corner to resize

Both approaches are valid. Here's how they compare:

Coolify Builds (This Guide)Registry-Based
VPS resource usageHigher: compiling happens on your serverLower: your VPS only pulls and runs images
CI minutesLower: CI only runs testsHigher: CI also builds and pushes Docker images
Pipeline complexitySimpler: fewer moving parts, no registry credentials to manageMore setup: registry auth, image tagging strategy, cleanup policies
Build consistencyBuilds happen on VPS hardware, which may differ from CIImages are identical everywhere, built once in a controlled environment
Deployment speedSlower: Coolify compiles from source each timeFaster: Coolify just pulls a ready-to-run image
RollbacksRequires a rebuild from a previous commitPull a previous image tag, nearly instant
Debugging buildsHarder: build logs are in Coolify, separate from CIEasier: everything is in one place (GitHub Actions)
When to Consider a Registry

If your VPS is resource-constrained (1-2 GB RAM), building Java applications with Gradle on it can be painful. Moving the build to GitHub Actions (which has 7 GB RAM on free runners) and pushing pre-built images to a registry is a practical solution.

This guide uses the "Coolify builds" approach because it's simpler to set up and doesn't require managing registry credentials or image retention policies. For a small project or learning setup, the reduced complexity is worth the tradeoff.

Repository Setup

Monorepo Example

This guide demonstrates deploying a monorepo containing three Spring Boot projects (Java, Kotlin, and Groovy implementations of the same API). While the setup is more complex than a single-project repo, the core concepts remain the same.

Before configuring Coolify, you need to prepare your repository with Docker and CI/CD configurations. Here's a summary of the new and modified files:

Files to Create/Modify
File Tree
springboot-demo-projects/
├── .github/
│ └── workflows/
│ └── ci-cd.yml
├── docker-compose.yml
├── .gitignore
├── spring_java/
│ ├── Dockerfile
│ └── settings-docker.gradle
├── spring_kotlin/
│ ├── Dockerfile
│ └── settings-docker.gradle
└── spring_groovy/
├── Dockerfile
└── settings-docker.gradle

docker-compose.yml

This file orchestrates all three Spring Boot services:

docker-compose.yml

services:
spring-java:
build:
context: .
dockerfile: spring_java/Dockerfile
container_name: spring-java
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped

spring-kotlin:
build:
context: .
dockerfile: spring_kotlin/Dockerfile
container_name: spring-kotlin
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped

spring-groovy:
build:
context: .
dockerfile: spring_groovy/Dockerfile
container_name: spring-groovy
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped

Each service:

  • Builds from its own Dockerfile located in the respective module directory
  • Maps port 8080 to the internal Spring Boot port (8080), and the external port matches because Coolify handles routing
  • Activates the prod profile for production-optimized settings
  • Includes a healthcheck using Spring Boot Actuator to ensure the container is actually ready
  • Restarts automatically if the container crashes

Dockerfiles

Each module gets its own optimized Dockerfile:

spring_java/Dockerfile
FROM gradle:jdk21-alpine AS build
WORKDIR /home/gradle/src

COPY --chown=gradle:gradle spring_java/settings-docker.gradle ./settings.gradle
COPY --chown=gradle:gradle gradle ./gradle
COPY --chown=gradle:gradle gradlew ./
COPY --chown=gradle:gradle spring_java ./spring_java

RUN gradle :spring_java:bootJar -x test -x spotlessApply -x spotlessCheck --no-daemon

FROM eclipse-temurin:21-jre-alpine
RUN apk add --no-cache curl
WORKDIR /app
COPY --from=build /home/gradle/src/spring_java/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

A few important details about this multi-stage build:

  • Stage 1 (Build): Uses the full Gradle image with JDK 21 to compile the application. We skip tests and Spotless formatting because those already ran in GitHub Actions.
  • settings-docker.gradle trick: Each module has a minimal settings file that only includes itself. This prevents Docker from trying to build the entire monorepo when we just want one service.
  • Stage 2 (Runtime): Uses a slim JRE-only Alpine image for a smaller attack surface and faster deployments.
  • curl installation: Required for the healthcheck to work.

settings-docker.gradle

Each module needs a standalone settings file for Docker builds:

spring_java/settings-docker.gradle
rootProject.name = 'springboot-demo-projects'

include 'spring_java'

This minimal configuration tells Gradle to only build the specific module, avoiding unnecessary compilation of the entire monorepo during the Docker build phase.

Gradle Wrapper Exception in .gitignore

gradle/wrapper/gradle-wrapper.jar is currently needed for Docker Compose deployment because:

  1. Current Docker configuration: Each Dockerfile uses gradlew to build the JARs during the Docker build process.
  2. Multi-stage builds: The build stage depends on Gradle to compile and package the applications.
  3. Settings customization: Dockerfiles use custom Gradle settings files.

Exclude it from being ignored:

.gitignore
# ...
!gradle/wrapper/gradle-wrapper.jar

.github/workflows/ci-cd.yml

The GitHub Actions workflow orchestrates the CI/CD pipeline:

.github/workflows/ci-cd.yml
name: CI/CD Pipeline

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build-and-test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle

- name: Make gradlew executable
run: chmod +x gradlew

- name: Build & test all modules
run: ./gradlew clean build

- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: '**/build/reports/**'

deploy:
needs: build-and-test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'

steps:
- name: Trigger Coolify deployment
run: |
curl -X GET "https://coolify.pollito.tech/api/v1/deploy?uuid=${{ secrets.COOLIFY_DEPLOY_UUID }}&force=false" \
--header "Authorization: Bearer ${{ secrets.COOLIFY_API_TOKEN }}" \
--fail-with-body

This workflow has two jobs:

  1. build-and-test: Runs on every push and pull request. It compiles all three modules, runs tests, and uploads test reports as artifacts.
  2. deploy: Only runs on pushes to main after build-and-test succeeds. It triggers Coolify's deployment webhook.

The workflow uses two secrets (COOLIFY_DEPLOY_UUID and COOLIFY_API_TOKEN) that you'll configure later in GitHub.

Coolify + GitHub Actions Setup

Now that your repository is ready, let's configure Coolify to work with GitHub Actions.

Step 1: Create the Coolify Resource

  1. Go to your project in Coolify and click + Add Resource.

    Coolify Pollito Tech Project P4wk08wwcw0g888ogk4w4kcw Environment O8cckkoowsw0cgo84owsgsg0 2026 02 06 18_11_48 3fdaf6361d579f2a21476743c9f6dddb
  2. Under Git Based, select Private Repository (with GitHub App).

    Coolify Pollito Tech Project P4wk08wwcw0g888ogk4w4kcw Environment O8cckkoowsw0cgo84owsgsg0 New 0fc323715ed22ce6d1a6b306695f4c4c
  3. Select your connected GitHub App.

    Coolify Pollito Tech Project P4wk08wwcw0g888ogk4w4kcw Environment O8cckkoowsw0cgo84owsgsg0 New 2026 02 06 20_52_51 9d63c91a76159877271e4501195bc8bc
  4. Configure the application:

    • Repository: Select your repository (e.g., springboot-demo-projects)
    • Branch: main
    • Build Pack: Docker Compose
    • Base Directory: /
    • Docker Compose Location: /docker-compose.yml
    File Extension Matters

    Make sure the Docker Compose location matches your actual file extension. It's easy to mix up .yml and .yaml, so Coolify will fail to find the file if they don't match exactly.

    Coolify Pollito Tech Project P4wk08wwcw0g888ogk4w4kcw Environment O8cckkoowsw0cgo84owsgsg0 New 2026 02 06 20_53_57 91b5c6be8b25e44990499eb750702bac

Step 2: Configure General Settings

Once created, go to the Configuration tab and set up your services:

  1. Domains: Add your custom domains:

    • https://sakila-java.your-domain.com:8080
    • https://sakila-kotlin.your-domain.com:8080
    • https://sakila-groovy.your-domain.com:8080
    Port Configuration

    This is crucial: Each service is configured to expose port 8080. Coolify's reverse proxy will handle routing traffic to each container based on the domain name. If you run into routing issues, check Coolify's documentation for more details.

  2. Docker Compose Editor: Verify your docker-compose.yml content is displayed correctly.

    Coolify Pollito Tech Project P4wk08wwcw0g888ogk4w4kcw Environment O8cckkoowsw0cgo84owsgsg0 Application Mooo884gwccg0wosk88g8csw 2026 02 06 20_58_48 0df4f9e1c349aa285c88c386e2861231

Step 3: Disable Auto Deploy

Navigate to the Advanced tab and uncheck Auto Deploy.

Disable Auto Deploy

Critical: You must disable Auto Deploy. If left enabled, Coolify will deploy on every git push, bypassing your GitHub Actions CI pipeline. This defeats the purpose of having tests gate your deployments.

Coolify Pollito Tech Project P4wk08wwcw0g888ogk4w4kcw Environment O8cckkoowsw0cgo84owsgsg0 Application Mooo884gwccg0wosk88g8csw Advanced 2026 02 06 20_54_38 567cd2cddcc8d8ed90b7f3153dc0d7ef

Step 4: Get the Deploy Webhook URL

Navigate to the Webhooks tab to find your deployment trigger URL:

Coolify Pollito Tech Project P4wk08wwcw0g888ogk4w4kcw Environment O8cckkoowsw0cgo84owsgsg0 Application Mooo884gwccg0wosk88g8csw Webhooks 2026 02 06 20_59_06 F682dd6f50c3038575b8184043ac8359

Copy the Deploy Webhook URL. It looks like:

https://coolify.your-domain.com/api/v1/deploy?uuid=YOUR_UUID&force=false

The uuid query parameter is the unique identifier for your Coolify resource. You will store this in GitHub as the COOLIFY_DEPLOY_UUID secret.

Step 5: Generate API Token

  1. Go to Keys & TokensAPI Tokens in the left sidebar.

  2. If API is disabled, enable it in Settings first.

  3. Create a new token:

    • Description: github-actions
    • Permissions: Only check deploy
    Minimal Permissions

    Only grant the deploy permission. The GitHub Actions workflow only needs to trigger deployments, not read sensitive data or modify resources.

    Coolify Pollito Tech Security Api Tokens 2026 02 06 21_31_39 4d5e985bb3ef9f25c4958976ddf3bbf7
  4. Copy the generated token. You will store this in GitHub as the COOLIFY_API_TOKEN secret.

Step 6: Configure GitHub Secrets

Go to your GitHub repository → SettingsSecrets and variablesActions, and add these repository secrets:

  • COOLIFY_API_TOKEN: The API token you just generated

  • COOLIFY_DEPLOY_UUID: The UUID from the webhook URL (the uuid query parameter value)

    Github FranBec Springboot Demo Projects Settings Secrets Actions 2026 02 07 02_59_45 5cdd2940fc3961c7de09e3190a66de70

Verification

Push your changes to the main branch and watch the magic happen:

  1. Check the GitHub Actions run to confirm the CI pipeline works:

    • Your workflow should trigger and show both build-and-test and deploy jobs
    • Look for the green checkmark indicating success
    Github FranBec Springboot Demo Projects Actions Runs 21766853844 2026 02 06 21_51_30 0ec53bcde9e41109bfe3a9fe207515b1
  2. After a successful deployment (usually 3-5 minutes), you'll see it in the Deployments tab:

    Coolify Pollito Tech Project P4wk08wwcw0g888ogk4w4kcw Environment O8cckkoowsw0cgo84owsgsg0 Application Mooo884gwccg0wosk88g8csw Deployment 2026 02 06 21_51_58 23eac7b92b7e93c5855fa754b4a363f0
  3. Finally, test your deployed API:

Terminal
curl -s https://sakila-java.pollito.tech/api/films/42 | jq                                                    
{
"instance": "/api/films/42",
"status": 200,
"timestamp": "2026-02-07T19:12:08.752369341Z",
"trace": "b4e26474-1dd7-4af9-9865-21c056a43b34",
"data": {
"description": "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies",
"id": 42,
"language": "English",
"lengthMinutes": 86,
"rating": "PG",
"releaseYear": 2006,
"title": "ACADEMY DINOSAUR"
}
}

curl -s https://sakila-kt.pollito.tech/api/films/42 | jq
{
"instance": "/api/films/42",
"status": 200,
"timestamp": "2026-02-07T19:12:16.4206552Z",
"trace": "58cf1f78-b195-41d4-8f43-0fc8b823899a",
"data": {
"id": 42,
"title": "ACADEMY DINOSAUR",
"description": "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies",
"releaseYear": 2006,
"rating": "PG",
"lengthMinutes": 86,
"language": "English"
}
}

curl -s https://sakila-groovy.pollito.tech/api/films/42 | jq
{
"instance": "/api/films/42",
"status": 200,
"timestamp": "2026-02-07T19:12:26.069297914Z",
"trace": "005d057a-cc83-47b5-9962-1bbc44862b03",
"data": {
"description": "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies",
"id": 42,
"language": "English",
"lengthMinutes": 86,
"rating": "PG",
"releaseYear": 2006,
"title": "ACADEMY DINOSAUR"
}
}

Congratulations! You've successfully set up a production-ready CI/CD pipeline for your Spring Boot monorepo. Now every push to main will automatically run tests before deploying, giving you confidence that production is always in a working state.