Skip to content

Deployment Tutorial

This tutorial walks you through deploying a Towlion application from fork to running service. By the end, you will have a live application on your own server with automatic TLS, a database, and continuous deployment from GitHub.

Before you start

This is a hands-on guide with concrete commands. For background on why the platform works this way, see Self-Hosting for the fork model and Deployment for pipeline internals.

Prerequisites

You will need:

  • A GitHub account
  • A Debian server (VPS from any provider — Hetzner, DigitalOcean, Linode, etc.)
  • A domain name you control (for DNS configuration)
  • A local machine with Git and SSH installed
Resource Minimum
CPU 2 cores
RAM 4 GB
Disk 50 GB

Step 1: Fork the app repository

Go to the application repository on GitHub (for example, towlion/app-template) and click Fork.

Then clone your fork locally:

git clone git@github.com:YOUR_USERNAME/app-template.git
cd app-template

Tip

If you are creating a new app rather than deploying an existing one, use the Use this template button on towlion/app-template instead of forking. This gives you a clean commit history.

Step 2: Provision a server

Create a Debian server from your preferred provider. Make sure:

  • Ports 22, 80, and 443 are open in the firewall
  • You can SSH in as a non-root user with sudo access

Verify access:

ssh deploy@YOUR_SERVER_IP

You should see a shell prompt. If this works, you are ready to bootstrap.

Step 3: Bootstrap the server

SSH into your server as root and run the bootstrap script. This installs Docker, creates the deploy user, starts all platform services, and generates credentials:

ssh root@YOUR_SERVER_IP
git clone https://github.com/towlion/platform.git /tmp/platform
sudo ACME_EMAIL=you@example.com bash /tmp/platform/infrastructure/bootstrap-server.sh

Tip

Set OPS_DOMAIN=ops.example.com to also enable the Grafana monitoring dashboard:

sudo ACME_EMAIL=you@example.com OPS_DOMAIN=ops.example.com bash /tmp/platform/infrastructure/bootstrap-server.sh

Verify the bootstrap was successful:

bash /tmp/platform/infrastructure/verify-server.sh

All checks should pass. The script creates:

/data/              # Persistent data (postgres, redis, minio, caddy, loki, grafana)
/opt/platform/      # Platform services and config
/opt/apps/          # Application deployments

Step 3.5: Clone your app on the server

The deploy workflow runs git pull (not git clone), so the app must be pre-cloned on the server. SSH in as the deploy user:

ssh deploy@YOUR_SERVER_IP
cd /opt/apps
git clone git@github.com:YOUR_USERNAME/app-template.git my-app
cd my-app

Provision per-app database and storage credentials:

sudo /opt/platform/infrastructure/create-app-credentials.sh my-app

Create the deploy environment file from the template:

cp deploy/env.template deploy/.env

The deploy workflow will auto-update deploy/.env with the correct credentials on the next push.

Warning

The deploy user needs SSH access to your GitHub repo to git pull. Add the deploy user's public key (/home/deploy/.ssh/id_ed25519.pub) as a deploy key on your GitHub repository, or use HTTPS cloning with a personal access token.

Step 4: Configure DNS

Go to your domain registrar or DNS provider and add an A record pointing to your server:

Type: A
Name: app          (or your chosen subdomain)
Value: YOUR_SERVER_IP
TTL: 300

For example, if your domain is example.com and your server IP is 203.0.113.42:

A record: app.example.com -> 203.0.113.42

Verify DNS propagation:

dig +short app.example.com

Expected output:

203.0.113.42

Tip

DNS propagation can take a few minutes to a few hours. Wait until dig returns your server IP before proceeding.

Step 5: Configure GitHub secrets

In your forked repository on GitHub, go to Settings > Secrets and variables > Actions and add the following repository secrets:

Secret Example value Description
SERVER_HOST 203.0.113.42 Your server's IP address
SERVER_USER deploy SSH username on the server
SERVER_SSH_KEY (private key contents) SSH private key for deployment
APP_DOMAIN app.example.com Domain pointing to your server

Note: Database and storage credentials are auto-generated by the bootstrap script on the server. You do not need to create them as GitHub secrets.

Optionally, add PREVIEW_DOMAIN (e.g., example.com) to enable preview environments for pull requests.

Generate a deploy SSH key

Create a dedicated key pair for deployment:

ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -N ""

Add the public key to your server:

ssh-copy-id -i ~/.ssh/deploy_key.pub deploy@YOUR_SERVER_IP

Copy the private key contents into the SERVER_SSH_KEY secret:

cat ~/.ssh/deploy_key

Paste the full output (including the -----BEGIN and -----END lines) into the secret value field on GitHub.

Step 6: Deploy

Push a commit to the main branch to trigger deployment:

git push origin main

GitHub Actions picks this up automatically. Go to the Actions tab in your repository to watch the workflow run.

Push to main
     |
     v
GitHub Actions
     |
     +-- Run tests
     +-- SSH into server
     +-- Pull latest code
     +-- Build containers
     +-- Start services
     +-- Run database migrations (inside container)
     +-- Health check

The workflow typically completes in 2-5 minutes.

Tip

If the workflow does not appear, check that the .github/workflows/deploy.yml file exists in your repository. Repositories created from the app template include this file by default.

Step 7: Verify

Once the workflow succeeds, check your application is running.

Test the health endpoint:

curl https://app.example.com/health

Expected response:

{"status": "ok"}

Open https://app.example.com in your browser. You should see your application with a valid TLS certificate (Caddy provisions this automatically via Let's Encrypt).

Your application is now live.

Updating your app

To deploy changes, commit and push to main:

git add .
git commit -m "feat: add new feature"
git push origin main

GitHub Actions runs the deployment pipeline automatically. The platform uses rolling updates so your application stays available during deploys.

To pull upstream changes from the original repository:

git remote add upstream https://github.com/towlion/app-template.git
git fetch upstream
git merge upstream/main
git push origin main

Troubleshooting

DNS not resolving

Symptom: dig +short app.example.com returns nothing.

Fix: Wait for DNS propagation (up to 48 hours in rare cases). Verify the A record is set correctly in your DNS provider's dashboard. Try flushing your local DNS cache:

# macOS
sudo dscacheutil -flushcache

# Linux
sudo systemd-resolve --flush-caches

SSH key rejected

Symptom: GitHub Actions workflow fails with Permission denied (publickey).

Fix: Verify the SERVER_SSH_KEY secret contains the full private key including header and footer lines. Ensure the corresponding public key is in ~/.ssh/authorized_keys on the server. Check that the key format is correct:

# The secret should start with:
-----BEGIN OPENSSH PRIVATE KEY-----

# And end with:
-----END OPENSSH PRIVATE KEY-----

Health check fails

Symptom: Deployment completes but curl https://app.example.com/health returns an error.

Fix: SSH into the server and check container status:

ssh deploy@YOUR_SERVER_IP
docker compose ps

All services should show Up status. Check application logs:

docker compose logs app --tail 50

Common causes:

  • Database migration failed — run docker compose exec app alembic -c app/alembic.ini upgrade head to retry migrations, and check docker compose logs app for errors
  • Missing environment variable — verify all secrets are set in GitHub
  • Port conflict — ensure no other service is using ports 80 or 443

Containers not starting

Symptom: docker compose ps shows containers in Restarting or Exit state.

Fix: Check the logs for the failing container:

docker compose logs postgres --tail 50
docker compose logs app --tail 50

If PostgreSQL fails to start, verify the /data/postgres directory exists and has correct permissions:

ls -la /data/postgres

TLS certificate not provisioning

Symptom: Browser shows a certificate warning when visiting your domain.

Fix: Caddy provisions TLS certificates automatically, but requires:

  1. DNS is correctly pointing to your server
  2. Ports 80 and 443 are open and reachable from the internet
  3. The domain is set correctly in your app configuration

Check Caddy logs:

docker compose logs caddy --tail 50

For more details on the deployment pipeline, see Deployment. For the full list of application requirements, see the App Specification.