If your application is already containerised, deploying it should be the easy part — but most teams end up wiring the same fragile bash by hand into a GitHub Action or a long-lived CI runner. This guide covers the shorter path: a Docker container deployment workflow on a VPS or dedicated server, driven by a [DeployHQ Shell Server](https://www.deployhq.com/support/servers/adding-a-server/shell-server), with a Git push as the only trigger.

Shell Servers are the right starting point when you control the host. You run `docker` on your own box, [DeployHQ](https://www.deployhq.com) runs the orchestration. If you're deploying to AWS ECS or EKS instead, hand off to the dedicated guide on [deploying containerised apps to ECS and EKS](https://www.deployhq.com/blog/deploying-applications-to-aws-ecs-eks-using-deployhq-shell-servers) — that workflow uses Custom Actions and ECR, and it covers the parts (task definitions, `services-stable` waits, `kubectl rollout`) that a Shell Server won't.

## When Shell Servers fit and when they don't

Shell Servers run your script over SSH against a host you own — a Hetzner, DigitalOcean, OVH, or on-prem box with the Docker daemon installed. Nothing is uploaded; only the commands you write run remotely. That fits four very common shapes:

- A single VPS running 1-10 containers behind Caddy, Traefik, or Nginx
- A small Docker Compose stack (app + Postgres + Redis) that needs a coordinated restart
- A self-hosted internal tool that you want updated on every push to `main`
- A staging server where you want every PR branch to deploy automatically

It does **not** fit ECS, EKS, GKE, Fargate, or any managed orchestrator — those have no shell to SSH into. For those, use [Custom Actions](https://www.deployhq.com/support/servers/adding-a-server/custom-action), which spin up a managed container per deploy and run AWS CLI or `kubectl` against the cluster API.

## Prerequisites

Before the first deploy you need:

- A [DeployHQ account on a plan that includes Shell Servers](https://www.deployhq.com/pricing)
- A Linux server (Ubuntu 22.04 LTS or 24.04 LTS recommended) reachable over SSH on port 22 (or a port you'll specify)
- **Docker Engine 24.0+** installed on that server. The [official Docker convenience script](https://docs.docker.com/engine/install/ubuntu/) is the fastest path; the distro-packaged `docker.io` lags by several releases and is missing BuildKit defaults
- **Docker Compose v2** if you're running multi-container stacks. It's bundled as `docker compose` (no hyphen) with modern Docker Engine — verify with `docker compose version`
- The deploy user added to the `docker` group (`sudo usermod -aG docker deploy && newgrp docker`) so it can run `docker` without `sudo`
- Read/write access to your container registry. For Docker Hub: a [Personal Access Token](https://docs.docker.com/security/for-developers/access-tokens/), not your account password. For AWS ECR: an IAM user with `ecr:GetAuthorizationToken`, `ecr:BatchGetImage`, and `ecr:PutImage`
- Your Git repository connected to [DeployHQ](https://www.deployhq.com) — both [deploy from GitHub](https://www.deployhq.com/deploy-from-github) and [deploy from GitLab](https://www.deployhq.com/deploy-from-gitlab) work the same way

The [Docker cheatsheet](https://www.deployhq.com/cheatsheets/docker) covers the host-side commands referenced throughout this guide — `docker system prune`, `docker image ls --filter`, `docker logs --since`, and the diagnostic flags you'll want when a deploy goes sideways.

## Step 1: Add the Shell Server to DeployHQ

Inside your project, go to **Servers & Groups \> New Server** and pick **Shell**. The fields that matter:

- **Hostname** : IP or DNS name of the box
- **Username** : the user with `docker` group membership
- **Port** : 22 unless you've moved it
- **SSH key** : paste your project's public key into the server's `~/.ssh/authorized_keys`. Password auth works but you'll regret it the first time you rotate
- **Working directory** : where your repo will be checked out for the deploy. `/srv/myapp` is a reasonable convention

![Add Shell Server in DeployHQ](https://blog.deployhq.com/attachment/e573b4b1-3700-4aab-a960-f3550948bdc2/img_1hvh3b3d90hg.png)

Smoke-test the connection with **Test Connection** before continuing. If it fails, 90% of the time it's `Permission denied (publickey)` because the key isn't in `authorized_keys`, or the wrong user.

## Step 2: Write a Dockerfile that's actually deployable

Most Docker container deployment problems are baked in at the Dockerfile stage. The minimal Dockerfile you find on Docker's getting-started page works for `docker run` on your laptop, but it has three problems that show up on the second deploy.

Here's a production-leaning Dockerfile for a Python Flask app. The patterns are the same for Node, Ruby, Go, or Java — only the base image and dependency-install commands change.

```
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS base

# Don't run as root in the container
RUN useradd --create-home --uid 1000 app

WORKDIR /app

# Install deps separately from app code so the layer caches across deploys
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# Now copy application code — invalidates only when code changes
COPY --chown=app:app . .

USER app

EXPOSE 5000

# tini handles SIGTERM properly; without it `docker stop` SIGKILLs after 10s mid-request
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "app.py"]
```

The three fixes most starter Dockerfiles get wrong:

1. **`COPY requirements.txt` before `COPY . .`** — keeps the dependency layer cached. Without it, `pip install` re-runs on every code change, turning 10-second builds into 90-second ones
2. **A non-root `app` user with `USER app`** — if your app process is compromised, the blast radius is the container's filesystem, not root in the container
3. **`tini` as the entrypoint** — Python and Node don't forward `SIGTERM` to child processes, so `docker stop` waits 10 seconds then `SIGKILL`s mid-request. `tini` is a 220KB init that reaps zombies and forwards signals correctly

The full pattern for Node, including multi-stage builds and the `.dockerignore` that goes with it, is in the [production-ready Node.js Dockerfile walkthrough](https://www.deployhq.com/blog/dockerize-nodejs-app).

Build and test locally before pushing — if it doesn't run on your laptop, the registry push is wasted work:

```
docker build -t myapp:dev .
docker run --rm -p 5000:5000 myapp:dev
```

## Step 3: Push to a container registry

Your Shell Server pulls the image; it doesn't build it. That means the build either happens (a) on a [DeployHQ](https://www.deployhq.com) [build pipeline](https://www.deployhq.com/features/build-pipelines) before the Shell Server step runs, or (b) on the Shell Server itself if it has spare CPU and RAM. Build pipelines are the cleaner pattern because they decouple build from deploy and surface failures earlier — see [what a build pipeline actually does](https://www.deployhq.com/blog/what-is-a-build-pipeline) for the model.

Tag with the Git commit SHA — never with `:latest` alone:

```
# In a DeployHQ build pipeline, ${REVISION} is the commit SHA
IMAGE=myorg/myapp:${REVISION}

docker login --username myorg --password-stdin <<< "${DOCKERHUB_TOKEN}"
docker build -t $IMAGE -t myorg/myapp:latest .
docker push $IMAGE
docker push myorg/myapp:latest
```

Why both tags? `:${REVISION}` is what you pull on the Shell Server (immutable, traceable, rollback-friendly). `:latest` is what your local `docker compose pull` grabs for development. If you only push `:latest`, you've thrown away your rollback target — a 30-second incident becomes a 30-minute one.

Set `DOCKERHUB_TOKEN`, `AWS_ACCESS_KEY_ID`, and any other secrets as **environment variables** in **Project Settings \> Environment Variables** , marked as protected so they don't leak into logs. If you need encrypted-at-rest secrets you can commit alongside your repo, the [Dotenvx encrypted env workflow](https://www.deployhq.com/blog/how-to-deploy-php-applications-with-encrypted-environment-variables-using-dotenvx-and-deployhq) handles that without a second secrets store. For AWS ECR registries, the login is a one-liner — see the [AWS CLI cheatsheet](https://www.deployhq.com/cheatsheets/aws-cli) for the exact `aws ecr get-login-password` invocation and the surrounding session patterns.

## Step 4: The Shell Server deploy script

This is the only script you actually maintain. It runs on the target host as the deploy user, with the repo _not_ uploaded — the Shell Server runs commands, it doesn't transfer files. Anything you need from the repo (a `docker-compose.yml`, Kubernetes manifests, migration scripts) either lives on the box already, gets fetched by the script, or comes from your registry inside the image.

For a single-container app:

```
#!/bin/bash
set -euo pipefail

IMAGE="myorg/myapp:${REVISION}"
CONTAINER=myapp

echo "Pulling $IMAGE..."
docker pull "$IMAGE"

echo "Stopping previous container (if any)..."
docker rm -f "$CONTAINER" 2>/dev/null || true

echo "Starting new container..."
docker run -d \
  --name "$CONTAINER" \
  --restart unless-stopped \
  -p 5000:5000 \
  --env-file /etc/myapp/.env \
  "$IMAGE"

echo "Waiting for health check..."
for i in {1..30}; do
  if curl -fsS http://localhost:5000/healthz >/dev/null; then
    echo "Healthy after ${i}s"
    docker image prune -f --filter "until=72h" >/dev/null
    exit 0
  fi
  sleep 1
done

echo "Health check failed — rolling back"
docker rm -f "$CONTAINER"
docker run -d --name "$CONTAINER" --restart unless-stopped \
  -p 5000:5000 --env-file /etc/myapp/.env "myorg/myapp:previous"
exit 1
```

Two details to call out:

- The 30-second health-check loop turns this from did the container start (always true) into is the new revision serving traffic. Without it [DeployHQ](https://www.deployhq.com) will report success even when the app is crash-looping. The loop is the same shape as the `services-stable` wait that ECS gives you for free
- `docker image prune --filter "until=72h"` keeps the disk from filling up with old image layers. Three days is enough to roll back to anything from the last week's deploys; tighten or loosen as your disk allows

For a Compose stack, the deploy is `docker compose pull && docker compose up -d --remove-orphans` — Compose handles the container churn and respects `depends_on` and health checks defined in the YAML. Keep the YAML in `/srv/myapp/docker-compose.yml` on the server, version-controlled separately or synced via a build pipeline.

> **Start the deploy script with `set -euo pipefail`.** Without it, a failed `docker pull` (image not found, registry down) returns non-zero, your script keeps going, and the next `docker run` happily starts the _previous_ image — silent rollback to yesterday's bug. `set -e` makes the script halt on the first failure; `-u` catches undefined variables; `-o pipefail` propagates exit codes through pipes.

## Step 5: Automate on every push

The whole point of this setup is that you don't run anything manually after the first deploy. In **Project Settings \> Automatic Deployments** , enable deployment for the branch (`main` for production, `staging` for staging) and copy the webhook URL into your GitHub or GitLab repository's **Settings \> Webhooks**. The [GitHub webhook setup guide](https://www.deployhq.com/support/deployments/automatic-deployments/github-webhook) walks through the exact steps; GitLab is identical in shape.

From here, the full loop is: `git push` → [DeployHQ](https://www.deployhq.com) runs the build pipeline (image build + push to registry) → [DeployHQ](https://www.deployhq.com) runs the Shell Server script (pull + restart + health check) → success or [one-click rollback](https://www.deployhq.com/features/one-click-rollback) on the dashboard if the health check failed.

Need to verify your project's deployment safety story before going live? [Sign up for DeployHQ](https://www.deployhq.com/signup) and run the first deploy against a staging server — the free tier includes one project and unlimited deploys, which is enough to wire up the full pipeline before you commit.

## Production gotchas this workflow doesn't solve

Shell Server deployments are good but they aren't free of trade-offs. Three you'll hit:

1. **No automatic blue/green.** The script above stops the old container and starts the new one, with a few seconds of 502 in between unless your reverse proxy (Caddy, Traefik, Nginx) has retry-on-fail configured. For real zero-downtime on a single host, run two container versions side by side behind the proxy and flip the upstream — covered in the [zero-downtime deployments](https://www.deployhq.com/features/zero-downtime-deployments) feature page
2. **No horizontal scale.** One host, one running version. For multi-host orchestration, ECS/EKS or a managed Kubernetes is the right next step
3. **State management is on you.** Volumes, named volumes vs bind mounts, Postgres data outside the container — Docker doesn't help you here. The deploy script must never destroy a volume on rollback. Use `docker volume ls` to audit what's persistent before automating

If you want a worked Compose example end-to-end on Ubuntu, the [Metabase Docker Compose walkthrough](https://www.deployhq.com/blog/how-to-deploy-metabase-on-ubuntu-20-04-with-docker-and-deployhq) covers the YAML, the systemd unit, and the same Shell Server deploy script applied to a multi-container stack.

## Conclusion

Docker container deployment with a [DeployHQ](https://www.deployhq.com) Shell Server replaces three things at once: the GitHub Action that's almost-but-not-quite right, the EC2 runner that nobody patches, and the bash one-liner in someone's terminal that nobody else can reproduce. You write one script, the Shell Server runs it on every push, the build pipeline keeps your image immutable per commit, and rollback is a single click. That's the workflow.

For the Docker primer if any of this felt like a fast skim, see [what Docker is and how containers, images, and registries fit together](https://www.deployhq.com/blog/what-is-docker). For the orchestrator-side command reference when you graduate to Kubernetes, the [kubectl cheatsheet](https://www.deployhq.com/cheatsheets/kubectl) is the page to bookmark.

* * *

Questions or running into a deploy that won't behave? Email us at [support@deployhq.com](mailto:support@deployhq.com) or ping us on [X (Twitter)](https://x.com/deployhq).

## FAQs

### What's the difference between a DeployHQ Shell Server and a Custom Action for Docker deployments?

A Shell Server runs your script over SSH on a host you own — your Hetzner box, your bare-metal server, your home lab. A Custom Action runs in a managed container that [DeployHQ](https://www.deployhq.com) spins up per deploy, with no persistent host to maintain — that's what you use for ECS, EKS, or anywhere there's no SSH-able machine. Shell Servers fit single-VPS and small Compose stacks; Custom Actions fit cloud-native orchestrators.

### Why tag images with the commit SHA instead of `:latest`?

Because rollback is then a single `docker pull myorg/myapp:<previous-sha> && docker run ...` against a specific, named image. With `:latest`-only, the previous version has been overwritten in the registry and there's nothing to roll back to. Tagging with `${REVISION}` (the commit SHA) gives you an immutable per-deploy artefact that pairs with the rollback button on the [DeployHQ](https://www.deployhq.com) dashboard.

### Do I need a build pipeline, or can the Shell Server build the image too?

Either works. The Shell Server can run `docker build` and `docker push` if it has spare CPU and RAM. A build pipeline is cleaner because it decouples build from deploy — the build runs on DeployHQ's infrastructure, the deploy runs on your server, and a build failure never touches the production host. For production setups, prefer the build pipeline.

### How do I keep registry credentials out of my repo?

Store them as **Environment Variables** under **Project Settings** , marked as protected. They get injected into the build pipeline and Shell Server session at deploy time. Never commit `.env` files, Docker config JSON, or AWS credentials into the repo — even private repositories leak via forks, mistakes, and `git log --all`.

### Can I deploy Docker Compose stacks the same way?

Yes — replace the `docker run` step in Step 4 with `docker compose pull && docker compose up -d --remove-orphans`. Keep the `docker-compose.yml` on the server (in the working directory you configured for the Shell Server) and let the script trigger Compose to reconcile. The same health-check loop applies — wait for the new containers to be healthy before reporting success.

