Manually Set Up a Hetzner VPS for Self-Hosting, Pt 2: Automated Deployment
In Part 1 we built the base host: Ubuntu, Podman, Caddy, and a domain serving traffic over HTTPS. In this part we will automate deployments so a merge to main pushes your latest code onto the server and restarts the app.
This is intentionally a simple pipeline. GitHub Actions will upload the source code to the server over SSH, the server will build the container locally with Podman, and systemd will keep the app running. It is not the fanciest setup, but it is very easy to understand and debug.
If you plan to use EasyRunner, this is useful background because it shows the kind of deploy flow a tool like EasyRunner is abstracting.
Note
We are still not doing the full security pass here. We will make a few good choices, like keeping runtime secrets on the server and pinning the SSH host key in GitHub Actions, but the broader hardening pass is for Part 3.
Before we start, I will use myapp as the app name and 3000 as the application port. Replace those with your real values.
Prerequisites
- A server configured from Part 1
- A GitHub repository for your app
- A
ContainerfileorDockerfilein that repo - An app that listens on a known port inside the container
- A non-root deploy user on the server, such as
deploy
What You Will Have At The End
- A GitHub Actions workflow that runs CI on branches and pull requests
- A deployment workflow that runs only on pushes to
main - Runtime environment variables stored on the server instead of in the repo
- A systemd user service that keeps your container running across restarts
- A simple rollback path based on previous release directories
Architecture
- Your app code lives in GitHub
- GitHub Actions runs CI on every branch push and pull request
- A push to
mainuploads a release bundle to the server over SSH - The server builds the image with Podman and restarts the app service
- Caddy keeps handling HTTPS and reverse proxying
In plain English, the deploy flow looks like this:
git push -> GitHub Actions -> upload release over SSH -> Podman build -> systemd restart -> Caddy -> browser
That gives you a workflow that is easy to reason about with only one trust boundary in the middle: GitHub Actions talking to your server over SSH.
1. Decide On The Deployment Shape
There are many ways to automate deployment. You could build images in CI, push them to a registry, and then pull them on the server. That is a good pattern, but it adds a registry and more credentials.
For a single VPS, a simpler approach is often better:
- GitHub Actions uploads the source code to the server
- The server builds the image locally with Podman
- systemd restarts the container
This is slower than prebuilding an image in CI, but it is much easier to inspect when something breaks.
2. Prepare A Home For The App On The Server
Log in as your normal deploy user and create a small directory layout:
mkdir -p "$HOME/apps/myapp/releases"
mkdir -p "$HOME/apps/myapp/shared"
mkdir -p "$HOME/.config/systemd/user"
I like this layout because it separates concerns cleanly:
releases/holds each uploaded version of the appshared/holds runtime data that should survive deployments, like.env- the systemd service lives in the user config directory
One more setup step makes user services survive reboots:
Run that once, replacing deploy with your actual username if needed.
3. Put Runtime Configuration On The Server
Keep runtime secrets on the server, not in GitHub Actions and not in the repo.
Create an environment file:
Add whatever your app really needs here, for example:
DATABASE_URLREDIS_URLJWT_SECRET- third-party API keys
This is a small but important separation. GitHub Actions is responsible for shipping code. The server is responsible for holding runtime secrets.
4. Create A systemd User Service For The App
We want the app to come back after a reboot and to restart cleanly after each deployment. systemd is the simplest reliable way to do that.
Create ~/.config/systemd/user/myapp.service:
[Unit]
Description=MyApp container
After=network-online.target
Wants=network-online.target
[Service]
Restart=always
RestartSec=5
TimeoutStopSec=20
ExecStartPre=-/usr/bin/podman rm -f myapp
ExecStart=/usr/bin/podman run --name myapp --env-file /home/deploy/apps/myapp/shared/.env -p 127.0.0.1:3000:3000 localhost/myapp:latest
ExecStop=/usr/bin/podman stop -t 10 myapp
[Install]
WantedBy=default.target
Why run the app this way?
- systemd gives you restart behavior and logs
- Podman still runs the actual container
- the app stays bound to
127.0.0.1, so only Caddy is public
If your app needs volume mounts, extra ports, or different environment files, add those flags to the ExecStart line.
Load the unit and enable it:
Do not worry if it is not running yet. The image does not exist until the first deployment.
5. Create The Server-Side Deploy Script
Now create the script GitHub Actions will call on each deployment.
Save this as ~/apps/myapp/deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="myapp"
APP_DIR="$HOME/apps/$APP_NAME"
RELEASE_ID="${1:?release id is required}"
RELEASE_DIR="$APP_DIR/releases/$RELEASE_ID"
if [ ! -d "$RELEASE_DIR" ]; then
echo "Release directory not found: $RELEASE_DIR" >&2
exit 1
fi
ln -sfn "$RELEASE_DIR" "$APP_DIR/current"
cd "$APP_DIR/current"
podman build -t "localhost/$APP_NAME:$RELEASE_ID" -t "localhost/$APP_NAME:latest" .
systemctl --user daemon-reload
systemctl --user restart "$APP_NAME.service"
systemctl --user --no-pager --full status "$APP_NAME.service"
Make it executable:
This script does four things:
- It picks the uploaded release directory based on the commit SHA.
- It updates a
currentsymlink to point at that release. - It builds a local Podman image tagged both with the commit SHA and
latest. - It restarts the systemd user service.
If your app needs database migrations, this script is the right place to run them just before the service restart.
6. Point Caddy At The Real App
In Part 1 we pointed Caddy at a tiny test container. Now swap that to your real app port.
For example:
sudo tee /etc/caddy/Caddyfile > /dev/null <<'EOF'
example.com, www.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
}
EOF
Validate and reload Caddy:
If you still have the Part 1 demo container running, clean it up:
7. Add Deployment Secrets In GitHub
In your GitHub repository settings, add these Actions secrets:
DEPLOY_HOST: your server IP or DNS nameDEPLOY_USER: your server user, for exampledeployDEPLOY_SSH_PRIVATE_KEY: the private key GitHub Actions will use to SSH to the serverDEPLOY_KNOWN_HOSTS: the pinned SSH host key entry for that server
From a trusted machine, you can get the host key entry like this:
Paste that exact output into the DEPLOY_KNOWN_HOSTS secret.
For the deploy key pair itself, create a dedicated SSH key just for GitHub Actions rather than reusing your personal admin key:
Then:
- add the public key to
/home/deploy/.ssh/authorized_keyson the server - store the private key contents in
DEPLOY_SSH_PRIVATE_KEY
That keeps the blast radius smaller if you ever need to rotate this credential.
8. Add A GitHub Actions Workflow
Create .github/workflows/deploy.yml in your app repository:
name: ci-cd
on:
pull_request:
push:
concurrency:
group: ci-cd-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build container image
run: docker build -t myapp-ci .
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure SSH
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Upload release bundle
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
RELEASE_ID: ${{ github.sha }}
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p ~/apps/myapp/releases/$RELEASE_ID"
git archive --format=tar HEAD \
| gzip \
| ssh "$DEPLOY_USER@$DEPLOY_HOST" \
"tar -xzf - -C ~/apps/myapp/releases/$RELEASE_ID"
- name: Build and restart on server
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
RELEASE_ID: ${{ github.sha }}
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" "~/apps/myapp/deploy.sh $RELEASE_ID"
This gives you the behavior we wanted:
- every pull request gets a CI run
- every branch push gets a CI run
- every push to
maingets CI and then deployment
Why use docker build in CI when the server uses Podman? Because GitHub-hosted runners already have Docker available, and both Docker and Podman build OCI-compatible images. For a simple build check, that is perfectly fine.
Replace these values for your own app:
myapp3000main, if you use a different default branch- the build command if your
Containerfileis not at the repo root
If you already have real test commands, add them to the ci job before or after the image build.
9. Test The Whole Flow
The cleanest way to test this is with a small harmless change.
- Make a visible change in the app.
- Open a pull request and confirm the
cijob passes. - Merge into
main. - Watch the
deployjob run in GitHub Actions. - Visit your site and confirm the new version is live.
Useful checks on the server:
systemctl --user status myapp.service
podman ps
podman logs myapp --tail 50
curl http://127.0.0.1:3000
sudo journalctl -u caddy -n 50 --no-pager
10. Roll Back If You Need To
One nice side effect of storing releases by commit SHA is that rollback is simple.
If a previous deployment is still present in ~/apps/myapp/releases, you can redeploy it directly:
That rebuilds the app from the older release directory and restarts the service.
For a small single-server setup, that is often good enough. You do not need a complicated rollout system on day one.
11. What We Are Deliberately Not Doing Yet
This pipeline is useful, but it is still the pragmatic version.
We have not yet covered:
- stricter SSH policy and key rotation
- running builds from a container registry instead of source uploads
- multi-server deployments
- zero-downtime deploy strategies
- deeper secret management
- automatic backup and restore flows
That is intentional. The goal here is to get from manual deploys to repeatable deploys without turning the setup into a science project.
Part 3 will cover the security hardening side in more detail.
Troubleshooting
The GitHub Action Cannot SSH Into The Server
Check these first:
- the public half of the deploy key is present in
/home/deploy/.ssh/authorized_keys DEPLOY_USERandDEPLOY_HOSTare correctDEPLOY_KNOWN_HOSTSmatches the actual server host key
The Deploy Job Passes But The Site Returns 502
That usually means Caddy is fine and the app is not listening where Caddy expects it.
Check these:
- the app really listens on port
3000inside the container - the systemd service publishes
127.0.0.1:3000:3000 - Caddy reverse proxies to
127.0.0.1:3000
The Service Restarts But The Container Fails Immediately
Usually that means one of three things:
- a missing environment variable
- the image built successfully but the app startup command is wrong
- the app now requires a migration or dependency that is not available
The fastest checks are:
Wrap Up
You now have a deployment flow that is boring in a good way:
- GitHub runs CI on code changes
- a push to
mainuploads a release to the server - Podman builds the new image locally
- systemd restarts the app
- Caddy keeps serving HTTPS at the edge
This is a strong place to be for a side project or early product. It is automated, understandable, and still small enough that you can debug every moving part yourself.
The next step is not more deployment complexity. The next step is hardening the host and the deploy path, which we will cover in Part 3.