Run a Controlled External Command from Postfix with spawn
You will add a local Postfix service that accepts one connection, starts an external command, and connects that command's standard input, output and error to the client. The example listens only on loopback and runs as nobody, so it is suitable for a controlled smoke test rather than an Internet-facing endpoint. Allow about twenty minutes, including a safe rollback.
The route
Jump straight to the step you need, or tick off Done means at the end.
This guide is for Postfix 3.8.6, installed here as package version 3.8.6-1ubuntu0.1. The service is security-sensitive: spawn needs root privilege through the Postfix process manager, but it refuses to run the external command as root or as the Postfix mail system owner.
1. Check the installed Postfix paths and account
Read the active configuration location and the account that owns the Postfix queue. These checks are ordinary commands and do not change the service:
$ postconf config_directory mail_owner
config_directory = /etc/postfix
mail_owner = postfix
$ getent passwd nobody
Your config_directory may differ. Keep the path printed by Postfix in the commands below. The example uses nobody only because it is normally present and is not the mail owner. Choose a dedicated, least-privileged account for a real integration, with access only to the files and devices the command needs.
Checkpoint: confirm the daemon version before relying on an example copied from another host:
$ postconf mail_version
mail_version = 3.8.6
2. Prepare a harmless test command
The child command receives the client connection on its standard input, output and error streams. A small script makes that wiring visible. Create it as root in a directory that the selected account can read:
# printf '%s\n' '#!/bin/sh' 'printf "spawn child: %s\\n" "$(id -un)"' 'cat' | install -o root -g root -m 0755 /dev/stdin /usr/local/sbin/postfix-spawn-echo
The command prints its account name, then copies input to output. The install command above reads from standard input and writes the named file. If your shell does not support this form, create the same two-line file with your normal root-only editor, then run chown root:root and chmod 0755 on it. Do not put a production command here until you have reviewed its input handling.
Verify the file without executing it:
# ls -l /usr/local/sbin/postfix-spawn-echo
-rwxr-xr-x 1 root root ... /usr/local/sbin/postfix-spawn-echo
# head -n 3 /usr/local/sbin/postfix-spawn-echo
3. Add a loopback spawn service
Back up master.cf before editing it. This is a reversible configuration change, but a malformed line can stop Postfix from accepting new connections after reload:
# cp --preserve=all /etc/postfix/master.cf /etc/postfix/master.cf.spawn-backup
Add these logical lines to master.cf. The first line declares a TCP service on port 10090 and the indented line supplies the spawn daemon and its required attributes:
127.0.0.1:10090 inet n n n - - spawn
user=nobody argv=/usr/local/sbin/postfix-spawn-echo
The loopback address prevents remote clients from reaching the service. The n in the unprivileged field is intentional: spawn requires the privilege needed to switch to the account named by user=. The argv= attribute must be last. Postfix executes the command directly, without passing it through a shell, so shell operators such as | and > are not interpreted.
Do not write user=root or user=postfix. The daemon rejects root and the mail system owner. Do not bind this test to 0.0.0.0 or a public address unless you have designed authentication, input limits and firewall rules for the command.
4. Check the configuration before reloading
Ask Postfix to validate its configuration. This is the first command in the workflow that needs elevated privileges:
# postfix check
A successful check normally produces no output and returns status zero. Confirm the service definition is visible to Postfix:
# postconf -M 127.0.0.1:10090/inet
127.0.0.1:10090 inet n n n - - spawn
user=nobody argv=/usr/local/sbin/postfix-spawn-echo
If postfix check reports a syntax error, do not reload. Compare the line with the example, check that the continuation starts with whitespace, and inspect the log for the exact file and line number.
5. Reload and test the connection
Reloading changes the running Postfix configuration, so do it only after the check passes:
# postfix reload
postfix/postfix-script: refreshing the Postfix mail system
Use a client that sends a short line and prints the reply. nc is commonly provided by the netcat package:
$ printf 'hello from spawn\n' | nc -N 127.0.0.1 10090
spawn child: nobody
hello from spawn
The exact reload message varies by package. The useful test result is the two-line response: the child ran as nobody and received the input. If your netcat has no -N, use its documented close-after-EOF option, or run nc 127.0.0.1 10090 interactively and close standard input after the test line.
Checkpoint: test the listener itself and then inspect recent Postfix messages if the connection fails:
$ ss -ltn '( sport = :10090 )'
LISTEN 0 ... 127.0.0.1:10090 0.0.0.0:*
# journalctl -u postfix --since '5 minutes ago' --no-pager
A failed child is reported by Postfix logging. Remember that spawn runs only one external command at a time, so a slow command serialises clients and can create a queue of blocked connections. Keep the command short, bounded and designed for one request at a time.
6. Remove the test service
When the test is complete, remove the two service lines from master.cf and reload. This is the clean undo:
# sed -i '/^127\.0\.0\.1:10090 inet /,+1d' /etc/postfix/master.cf
# postfix check
# postfix reload
Review the diff before running the first command if master.cf contains another service with a similar name. If the edit did not do exactly what you intended, restore the backup instead, then check and reload:
# cp --preserve=all /etc/postfix/master.cf.spawn-backup /etc/postfix/master.cf
# postfix check
# postfix reload
After confirming the service is gone, remove the test script and backup only when you no longer need them. Those removals are irreversible:
# rm /usr/local/sbin/postfix-spawn-echo /etc/postfix/master.cf.spawn-backup
Done means
postfix checkpasses with the service definition present.- The listener is bound to
127.0.0.1, not a public interface. - The child reports the selected non-privileged account and echoes one test line.
- The command is executed directly, with no shell interpretation of its arguments.
- The test service is removed, or its backup is retained with a deliberate recovery plan.