Automated deployment / CI
A simple GitHub-Actions-driven deploy pipeline. The workflow SSHes into your server and triggers a small deploy.sh script, which freezes the compose config, pulls the latest image (or rebuilds it locally), and restarts the stack.
The same skeleton works for both Mounted host directory and Dockerfile strategy deployments — only the compose.prod.yaml differs. See Per-strategy compose differences below.
Prerequisites
- Project is checked out on the server (e.g. via
git clone) at a known path - Docker + Docker Compose installed on the server
- An SSH key whose public part is in the server's
~/.ssh/authorized_keys - The corresponding private key stored as a GitHub secret (
SSH_PRIVATE_KEY), plusREMOTE_HOSTandREMOTE_USER
GitHub Actions workflow
.github/workflows/deploy-prod.yml in your application repo (not this image's repo):
name: Deploy to Production
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check required secrets
run: |
[ -z "${{ secrets.REMOTE_HOST }}" ] && echo "REMOTE_HOST secret is missing" && exit 1
[ -z "${{ secrets.REMOTE_USER }}" ] && echo "REMOTE_USER secret is missing" && exit 1
[ -z "${{ secrets.SSH_PRIVATE_KEY }}" ] && echo "SSH_PRIVATE_KEY secret is missing" && exit 1
echo "All required secrets are available."
- name: Execute remote deploy script
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.REMOTE_HOST }}
username: ${{ secrets.REMOTE_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/my-app
git reset --hard
git clean -df
git pull
chmod +x deploy.sh
./deploy.shworkflow_dispatch makes this a manually-triggered button in the GitHub Actions UI. Swap for push to a release branch, tag triggers, or any other event when you want it to fire automatically.
git reset --hard && git clean -df before git pull ensures a clean checkout even if something on the server got accidentally modified.
deploy.sh
Place this in your application repo's root. Executable bit is set by the workflow before it runs.
#!/bin/sh
export DEPLOYMENT_ID=$(date +%s)
$(which docker) compose -f compose.prod.yaml config > compose.compiled.yaml
if [ -f compose.yaml ]; then
rm -f compose.yaml
fi
mv compose.compiled.yaml compose.yaml
$(which docker) compose pull
$(which docker) compose up -d --remove-orphans
sleep 2
$(which docker) image prune --all --force
echo "Deployed successfully!"What each step does
DEPLOYMENT_ID=$(date +%s)— a Unix-timestamp env var passed into the compose config. The image's entrypoint can read it; changing it triggers a recreate even if the image digest didn't change. Useful when you want a forced restart on every deploy (cache busts, re-running migrations, etc.).docker compose config— renderscompose.prod.yamlinto a fully-resolvedcompose.compiled.yaml(env vars substituted, defaults filled in, anchors expanded). The result is then moved tocompose.yamlso subsequentdocker composecommands pick it up by default — no-fflag needed afterwards.docker compose pull— fetches new image layers if the tag pointer moved (e.g., you bumped the image version incompose.prod.yaml).docker compose up -d --remove-orphans— recreates changed services in detached mode.--remove-orphanscleans up containers from services that were removed from the compose file.sleep 2 && docker image prune --all --force— frees disk space by removing unused images. The 2-second wait gives Docker time to release references on the previous image.
Securing the SSH access
- Use a dedicated deploy user on the server, not root. Limit it to the project directory and Docker socket.
- Restrict the SSH key with
command=in~/.ssh/authorized_keysif you want it to ONLY run the deploy script:Then the GitHub Action doesn't need to send a multi-linecommand="cd /var/www/my-app && ./deploy.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA…script:— the SSH connection itself triggers the deploy. - Rotate the key periodically; store only the private half as a GitHub secret.
Per-strategy compose differences
The workflow + deploy.sh skeleton above stays identical. What changes is what's inside compose.prod.yaml (the file deploy.sh materializes into compose.yaml):
Mounted host directory
services:
php:
image: ghcr.io/jonaaix/laravel-aio:1.3-php8.5-fpm
volumes:
- ./:/app:cachedCode lives on the server (git pull keeps it fresh). The image is just the runtime. docker compose pull updates the runtime image when you bump the tag in compose.prod.yaml.
Dockerfile strategy — built on the server
services:
php:
build:
dockerfile: ./Docker/production/Dockerfile
context: ./docker compose up -d rebuilds the image locally during deploy from the freshly pulled source. No registry needed. Slightly slower deploys (Docker layer cache helps), but zero infrastructure beyond the server itself.
Dockerfile strategy — built in CI, pulled from registry
services:
php:
image: ghcr.io/your-org/your-app:${IMAGE_TAG:-latest}A separate CI job (e.g. a build-and-push workflow that runs first) builds the image and pushes it to GHCR / Docker Hub / a private registry. deploy.sh only pulls and restarts. You can drop the git pull from the SSH script entirely if the server doesn't need the source — only the compose.prod.yaml and deploy.sh are required there.
The choice is operational, not technical: how much you want to lean on a registry vs. building on the server. The deploy script doesn't care.
Extending the workflow
Common additions:
- Run tests before deploy: add a
testjob that depends on a clean checkout, then makedeployneeds: test - Notify a chat channel: append a final step using a Slack/Mattermost webhook action
- Multi-environment: matrix over
stagingandprodwith different secrets per environment - Health check after deploy:
curlyour app's/healthendpoint and fail the job if it doesn't return 200 within N seconds