Containerising an application that already works is not a technical problem so much as an archaeology problem. The container part takes twenty minutes; finding everything the existing service touches — the cron job somebody added, the directory it writes to that is not in its configuration, the environment variable set in a systemd drop-in two years ago — is the job.

About two hours for a straightforward service, most of it in step 1.

What this guide does not do. It does not make the application better, faster or more reliable — a container is a packaging change, not an improvement. It does not cover clustering or Kubernetes. And it is worth being honest that not every service should be containerised: something that has run happily from a distribution package for five years, gets security updates automatically, and nobody needs to move, is fine where it is. Do this when you want reproducibility, isolation or a specific version your distribution does not carry — not because containers are the fashion.

1. Find out what it actually touches

This is the whole job. Everything you miss here becomes a problem after the cutover, when the old service is stopped and you have forgotten what it did.

systemctl cat myapp                    # the unit AND every drop-in override
systemctl show myapp | grep -E 'Environment|User|WorkingDir|ExecStart'
sudo ss -tlnp | grep myapp             # what it listens on
sudo lsof -p $(pidof myapp) | grep -v ' mem '   # every file it has open right now
dpkg -L myapp | grep -vE '^/usr/(share|lib)/'   # what the package installed
sudo find / -xdev -user myapp -newer /etc/hostname 2>/dev/null | head -50

That last command is the one that finds the surprises: files owned by the service account that have been written to recently, anywhere on the filesystem. Upload directories outside the configured data path, caches in /var/tmp, a SQLite database somebody put in /opt.

Then check the things that live outside the service entirely:

sudo crontab -l -u myapp
ls -la /etc/cron.d/ | grep -i myapp
systemctl list-timers | grep -i myapp
sudo logrotate -d /etc/logrotate.d/myapp     # log paths, and who rotates them
grep -rl myapp /etc/nginx /etc/caddy 2>/dev/null

Verify: write the list down — ports, data directories, config files, secrets, scheduled jobs, log paths, the user and its numeric UID. If you cannot produce that list, you are not ready for step 2.

2. Pick the image, and read what it expects

Prefer an image published by the project itself, then a well-known community one, then building your own. Whichever you choose, its documentation tells you three things you need: where it expects configuration, where it stores data, and which user it runs as.

docker pull example/myapp:2.4.1
docker image inspect example/myapp:2.4.1 \
  --format '{{.Config.User}} {{json .Config.ExposedPorts}} {{json .Config.Volumes}}'
docker run --rm example/myapp:2.4.1 --version

Match the version to what you are running now. Containerising and upgrading at the same time means that when something breaks you will not know which change caused it — do them in that order, a week apart.

Verify: the image runs and reports the same version as the installed package.

3. Sort out the UID before you touch any data

A bind mount carries UID numbers across the boundary, and nothing translates them. Your data is owned by myapp, which is UID 998 on the host. Inside the image, the application runs as UID 1000 with a different name. Neither side knows about the other — the container sees files owned by a number it does not recognise and cannot write to them. This is the single most common failure when moving data into a container, and it looks like a mysterious permission bug.

id myapp                                   # the host UID and GID
stat -c '%u %g %n' /var/lib/myapp           # who owns the data now
docker run --rm example/myapp:2.4.1 id      # who the image runs as

Two ways to reconcile them. Either tell the container to run as the existing UID, with user: "998:998" in the compose file — usually the cleanest — or chown the data to match what the image expects. Pick one deliberately; doing neither and hoping is how the next hour disappears. The mechanics are in Containers.

Verify: the numeric UID in the container’s id output matches the owner of the data directory.

4. Write the compose file, and run it in parallel

Do not stop the old service. Run the container alongside it on a different port, against a copy of the data, and prove it works before anything is at risk.

sudo systemctl stop myapp                        # briefly, for a consistent copy
sudo cp -a /var/lib/myapp /srv/myapp/data-test
sudo -u postgres pg_dump myapp > /srv/myapp/test.sql
sudo systemctl start myapp                       # old service back up immediately
# /srv/myapp/compose.yaml
services:
  app:
    image: example/myapp:2.4.1
    restart: unless-stopped
    user: "998:998"
    env_file: .env
    ports:
      - "127.0.0.1:8081:8080"     # 8081 while the old one still has 8080
    volumes:
      - ./data-test:/var/lib/myapp
      - ./config:/etc/myapp:ro
cd /srv/myapp
chmod 600 .env
docker compose up -d
docker compose logs -f app
curl -sI http://127.0.0.1:8081/

Verify: both are running at once, on different ports, and the containerised copy serves the same content. Log in to it. Click through the parts you actually use.

5. Cut over

disable the old service, do not merely stop it. A stopped-but-enabled service comes back at the next reboot. Best case it fails to start because the container has the port; worst case it starts anyway and you have two processes writing to the same data directory, which corrupts things quietly. This is the step that produces the three-weeks-later mystery.

# 1. take a real backup first
restic backup /var/lib/myapp /etc/myapp --tag pre-container

# 2. stop and DISABLE the old service, and any timers or cron jobs
sudo systemctl disable --now myapp
sudo systemctl disable --now myapp-cleanup.timer
sudo crontab -u myapp -l          # move these into the container or a host timer

# 3. move the real data in
sudo rsync -a --delete /var/lib/myapp/ /srv/myapp/data/

# 4. switch the port back and start it
sed -i 's/127.0.0.1:8081:8080/127.0.0.1:8080:8080/' compose.yaml
docker compose up -d

# 5. point the proxy at it and reload
sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy

Verify: the public URL works, systemctl is-enabled myapp says disabled, and ss -tlnp shows only the container on the port.

6. Prove it survives a reboot, now

sudo reboot
# then, once it is back
docker compose -f /srv/myapp/compose.yaml ps
systemctl is-enabled myapp          # should be "disabled"
curl -sI https://myapp.example.com | head -1

This catches the two things that are otherwise found at the worst possible moment: a missing restart: unless-stopped, and an old service that was stopped rather than disabled coming back to fight over the port.

Verify: the service is up after the reboot with no intervention, and only one thing is bound to the port.

7. Keep the old one recoverable for a week

Resist tidying up immediately. Leave the package installed, leave /var/lib/myapp where it was, and keep the pre-cutover backup. If something turns out to be broken on day four, going back is then a two-minute operation rather than a restore.

# after a week of it working
sudo apt remove myapp             # not purge yet — that removes config too
sudo mv /var/lib/myapp /var/lib/myapp.old
# and a week after that, when nothing has complained
sudo rm -rf /var/lib/myapp.old

What is now different

BeforeNow
Logsjournalctl -u myappdocker compose logs — and they are not in the journal
Updatesapt upgrade, automaticallyYou change an image tag. Nothing is automatic
Security patchesYour distribution’s, unattendedWhenever the image publisher rebuilds
Restart policysystemdThe container runtime
Firewallufw protected the portIt does not. Bind to 127.0.0.1
BackupsA directoryVolumes plus the compose file and env file
Resource limitssystemd directivesCompose mem_limit and cpus

The second and third rows are the real cost, and they are permanent. A packaged service gets security updates from your distribution without you doing anything; a container gets them when you notice, pull, and restart. That is a maintenance obligation you have just accepted — put a reminder somewhere, or adopt something that watches image tags for you.

The logging change catches people too: your existing monitoring and log rotation were built around the journal and /var/log, and neither now sees this service.

Before you call it done

  • You produced an inventory before touching anything, and worked through all of it
  • The container runs as a UID that owns the data
  • Every published port is bound to 127.0.0.1
  • The old service is disabled, not stopped, along with its timers and cron jobs
  • You rebooted, and only the container came back
  • A pre-cutover backup exists and the old data directory is still there
  • Backups now cover the volumes, the compose file and the environment file
  • Something reminds you to update the image, because nothing does it for you

If the next question is what happens when something other than you decides where the container runs, that is the level below this guide. Kubernetes, Honestly follows a manifest through all seven stages between kubectl apply and a running container, and hands off at the same boundary this guide does — the moment the runtime is asked to start something. It is also the place to look if you have been told the inventory step above is unnecessary once you are on Kubernetes. It is not; it is the same list, and the cluster will not find it for you.

Related