This example deploys a long-running app so that the repository supplies only a Dockerfile and source, while the host owns all runtime configuration. build.sh fetches the branch into a bare repository, exports the pushed commit with git archive into a fixed source directory, and runs docker compose up -d --build --wait on a compose file that lives on the host. The build fails if the app does not pass the healthcheck in app/compose.yaml within the --wait-timeout set in build.sh; without a healthcheck, it only fails if the container never reaches the running state. A malicious push can change what the build runs but cannot grant it host access. The script then prunes the daemon-wide build cache and the project's untagged images.
The pieces fit together as follows:
build.sh: the build script; see Deployment Steps for where it goes. Set the repository path (appended toGIT_URL) anddeploy_dirat the top; the branch comes fromWATCH_PUSH_BRANCH. The script reads the compose project name fromapp/compose.yaml, so the name is set in one place.compose.yaml: the deploy project with thehookedandtunnelservices. Thehookedservice mounts the deploy directory at the same absolute path as on the host, becausedocker composeruns in the hooked container, where it resolves any bind mounts and secret files inapp/compose.yaml. This path must matchdeploy_dirinbuild.sh. Route the tunnel's public hostnames tohttp://hooked:3000for the webhook andhttp://app:<port>for the app.app/compose.yaml: the app as a separate compose project, so thatupcan never recreate the hooked container that is running the build. Itsbuild.contextis the exported source, relative to this file. Hardening options andbuild.network: noneare commented out because they can break an app..env.example: copy it to.envand setDOCKER_GID; see Docker Socket.
The deploy directory must be writable by the user hooked runs as, because build.sh replaces src/ in it on each build.
The app and the tunnel service are in different projects, so they share a network named my-app-tunnel; give it a name unique on the host. The deploy project defines it and attaches only the tunnel service to it, and app/compose.yaml joins it as external. hooked stays off it, so the app cannot reach hooked directly. Start the deploy project first, because it creates the network. Rollback is a git revert and a push; the image is rebuilt under the same name and not tagged per commit.
The build context does not need the same path on the host and in the container: docker compose build streams it to the daemon, and a relative build.context resolves against the directory of the compose file. Only bind mounts and secret files in app/compose.yaml need matching paths.
For what a repository Dockerfile can and cannot reach during the build, see Dockerfile Build Isolation.
Run these steps in the directory you deploy from. The shared setup is explained in the examples README.
- Copy
compose.yamland.env.examplefrom this directory into the deployment directory. Copy.env.exampleto.env, setDOCKER_GID, and optionally setHOST_UIDandHOST_GID; see Docker Socket and Running as Your Host User. - Create
.srv/hooked/as the user that will run hooked. - Copy
build.shto.srv/hooked/build.shand runchmod +x .srv/hooked/build.sh. Thehookedservice mounts./.srv/hooked/at/app/and setsBUILD_ENTRY_SCRIPT_PATH=./build.sh, so this is the file hooked runs. - Edit the variables at the top of
.srv/hooked/build.sh: the repository path inrepo_url(<owner>/<repo>) anddeploy_dir. - In
compose.yaml, replace/path/to/deployin thehookedvolumes with the samedeploy_dir, on both sides of the colon. Setname:, thetunnelnetworkname:,WATCH_PUSH_BRANCH, andGIT_HOST(orGIT_URLandGIT_SSH_HOST) as needed; see Project Name. - Create the deploy directory at the
deploy_dirpath, owned by the user hooked runs as, and copyapp/compose.yamlto<deploy_dir>/app/compose.yaml. Edit it: setname:, set the networkname:to the same network name as in the deploycompose.yaml, enable thehealthcheckwith the app's port, and add the secrets, volumes, and hardening options the app needs. Bind mounts and secret files in this file must use paths that exist both on the host and in the hooked container, such as paths underdeploy_dir. - Create the secret files in
.secrets/:webhook_secret: the secret token you set on the repository webhook.git_token: an access token forGIT_URL. Leave the file empty for a public repository; git then runs unauthenticated. Keep the file, becausecompose.yamlstill references it and the container cannot start without it.discord_webhook_url: the Discord webhook URL. Leave the file empty to disable Discord notifications; hooked treats an empty value as unset. Keep the file, becausecompose.yamlstill references it and hooked exits if it cannot read/run/secrets/discord_webhook_url.cf_tunnel_token: the Cloudflare Tunnel token.
- Start the deploy project with
docker compose up -d. Do this before the first push, because it creates thetunnelnetwork thatapp/compose.yamljoins as external. - Configure the webhook on the repository as described in Usage, with the same secret as
.secrets/webhook_secret. - In the Cloudflare Tunnel, route one public hostname to
http://hooked:3000for the webhook and another tohttp://app:<port>for the app. Use the hooked hostname as the webhook URL's base, for examplehttps://<hooked-hostname>/webhooks/github. - Push to the watched branch. The first build creates the bare repository, exports the source into
<deploy_dir>/src, and starts the app project. To update the tunnel, see Cloudflare Tunnel.