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:
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:
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:
Verify the bootstrap was successful:
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:
Create the deploy environment file from the template:
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:
For example, if your domain is example.com and your server IP is 203.0.113.42:
Verify DNS propagation:
Expected output:
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:
Add the public key to your server:
Copy the private key contents into the SERVER_SSH_KEY secret:
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:
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:
Expected response:
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:
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:
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:
All services should show Up status. Check application logs:
Common causes:
- Database migration failed — run
docker compose exec app alembic -c app/alembic.ini upgrade headto retry migrations, and checkdocker compose logs appfor 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:
If PostgreSQL fails to start, verify the /data/postgres directory exists and has correct permissions:
TLS certificate not provisioning
Symptom: Browser shows a certificate warning when visiting your domain.
Fix: Caddy provisions TLS certificates automatically, but requires:
- DNS is correctly pointing to your server
- Ports 80 and 443 are open and reachable from the internet
- The domain is set correctly in your app configuration
Check Caddy logs:
For more details on the deployment pipeline, see Deployment. For the full list of application requirements, see the App Specification.